"""Authorization utilities.""" from typing import Literal import ddtrace from flask import g from python_pdp_sdk.resource_getters import base from users import config, constants from users.models import identities as identities_model from users.utils import email as email_utils Action = Literal['list_employees', 'validate_invitation'] def _normalize_auth0_id(auth0_id: str) -> str: """Prefix a bare auth0 id with ``auth0|`` (mirrors the handlers).""" return auth0_id if 'auth0' in auth0_id else f'auth0|{auth0_id}' def _is_employee_identity(identity: dict) -> bool: """True when the target identity is an employee (node flag or email domain).""" if identity.get('is_employee'): return True identity_email = identity.get('email') return bool(identity_email and email_utils.is_employee_email(identity_email)) def _deny_email_change(reason: str, auth0_id: str) -> bool: """Log the denial and return False (deny).""" g.log.warning( 'Email change authorization denied', resources={ 'reason': reason, 'auth0_id': auth0_id, 'caller_identity_id': getattr(g.request_context, 'jwt_identity_id', None), }, ) return False def _can_change_email( identity: dict, new_email: str, is_owner: bool, auth0_id: str ) -> bool: """Return True when ``new_email`` may be applied to ``identity``. A no-op change (same email, case-insensitive) is always allowed. Otherwise an employee's email may never be changed here; a non-employee's email may be changed only by the owner; and the new email may not be an employee domain. """ if new_email.lower() == (identity.get('email') or '').lower(): return True if _is_employee_identity(identity): return _deny_email_change('employee_email_change_forbidden', auth0_id) if not is_owner: return _deny_email_change('email_change_not_owner', auth0_id) if email_utils.is_employee_email(new_email): return _deny_email_change('email_change_to_employee_domain', auth0_id) return True @ddtrace.tracer.wrap() def authorize_email_change(auth0_id: str, new_email: str) -> bool: """Return True when the caller is allowed to change the target's email. Denies when there is no authenticated caller (``jwt_identity_id``) or when ``auth0_id`` resolves to no identity. Otherwise the policy in ``_can_change_email`` decides: an employee's email is immutable here, a non-employee's may be changed only by the owner, and never to an employee domain. """ caller = getattr(g.request_context, 'jwt_identity_id', None) if not caller: return _deny_email_change('missing_jwt', auth0_id) identity = identities_model.get_identity_by_auth0_id_dict(_normalize_auth0_id(auth0_id)) if not identity or not identity.get('id'): return _deny_email_change('target_not_found', auth0_id) is_owner = caller == identity.get('id') return _can_change_email(identity, new_email, is_owner, auth0_id) def pdp_authorize_action(action: Action) -> bool: """Authorize the specified action.""" authorized = config.pdp_authorization_backend.is_authorized( action=action, resource_id=0, resource_type='identity', resource_getter=base.ForwardKwargsGetter(), # These kwargs are unused--they're here just to pass schema validation identity_uuid=g.request_context.jwt_identity_id, tenant={ 'tenant_uuid': 'stub-tenant-uuid', 'tenant_type': 'parent_company', }, ) if not authorized: g.log.warn( constants.ERROR_CODE_AUTHORIZATION_ERROR, resources={ 'identity_id': g.request_context.jwt_identity_id, 'resource_id': 0, 'resource_type': 'identity', 'auth_response': authorized, }, ) return False return True @ddtrace.tracer.wrap() def pdp_authorize_list_employees() -> bool: """Authorize the list employees action.""" return pdp_authorize_action('list_employees') @ddtrace.tracer.wrap() def pdp_authorize_validate_invitation() -> bool: """Authorize the validate invitation action.""" return pdp_authorize_action('validate_invitation')