from functools import wraps from http import HTTPStatus import inspect import logging from typing import Any, Callable, Type from flask import g, request from jwtauth.exceptions import JWTAuthError from owsrequest.constants.headers import ORCHARD_PROFILE_TYPE from owsresponse import response from python_pdp_sdk import ForwardKwargsGetter from payee.config import authorization_backend, Config as config from payee.constants.constants import ( COLLABORATOR_RESOURCE_NAME, LABEL_RESOURCE_NAME, PERMISSIONS_ACTIONS, PROFILES, ) from payee.constants.error import ( ERROR_DONT_HAVE_PERMISSIONS, ERROR_NO_VALID_IDENTITY_IN_CONTEXT, ERROR_NOT_PERMITTED_IDENTITY, ) from payee.constants.features import is_abacus_tap_bypass_pdp_check_enabled from payee.models.account_payee import AccountPayee from payee.models.payee import Payee from payee.utils.validations import ( ResourceAccessCheck, validate_multiple_record_owner, validate_record_owner, ) logger = logging.getLogger('permissions') def _get_account_ids_from_account_payee_ids(account_payee_ids: list[int]) -> list[int]: if not account_payee_ids: return [] unique_ids = list(set(account_payee_ids)) accounts = AccountPayee.get_payees_by_ids(unique_ids) if len(unique_ids) != len(accounts): return [] return [account.account_id for account in accounts] def has_obscure_pii(obj: Type | Callable) -> bool: sig = inspect.signature(obj) return 'obscure_pii' in sig.parameters def _execute_with_obscured_flag(func, args, kwargs, obscure_pii=False): if has_obscure_pii(func): kwargs['obscure_pii'] = obscure_pii return func(*args, **kwargs) def _extract_account_payee_ids_from_request(request): """Extract account_payee_ids from request body.""" request_body = request.get_json(silent=True, force=True) if not request_body: return [] if isinstance(request_body, list): return [ item.get('accountPayeeId') for item in request_body if 'accountPayeeId' in item ] if isinstance(request_body, dict): if account_payee_id := request_body.get('account_payee_id'): return [account_payee_id] return request_body.get('account_payee_ids', []) return [] def _build_resource_access_check(request, **kwargs) -> ResourceAccessCheck: """Create a helper object to perform a check for resource access.""" if payee_id := kwargs.get('payee_id'): return ResourceAccessCheck( type=COLLABORATOR_RESOURCE_NAME, ids=[Payee.get_by_id(payee_id).get_typed_payee().collaborator_id], ) if payee := kwargs.get('payee'): if isinstance(payee, AccountPayee): return ResourceAccessCheck( type=LABEL_RESOURCE_NAME, ids=[payee.account_id], ) return ResourceAccessCheck( type=COLLABORATOR_RESOURCE_NAME, ids=[payee.get_typed_payee().collaborator_id], ) if account_payee_id := kwargs.get('account_payee_id'): account_payee_ids = [account_payee_id] else: account_payee_ids = _extract_account_payee_ids_from_request(request) return ResourceAccessCheck( type=LABEL_RESOURCE_NAME, ids=_get_account_ids_from_account_payee_ids(account_payee_ids or []), ) def check_access( resource_type='', action=PERMISSIONS_ACTIONS.VIEW, allowed_profiles=None, bypass=False, ): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): error_response = ( {'error': ERROR_DONT_HAVE_PERMISSIONS}, HTTPStatus.UNAUTHORIZED, ) profile_type = request.headers.get(ORCHARD_PROFILE_TYPE) if allowed_profiles and profile_type not in allowed_profiles: return error_response if profile_type == PROFILES.DOCUMENTS: access_check = _build_resource_access_check(request, **kwargs) if validate_record_owner(access_check): return _execute_with_obscured_flag(func, args, kwargs) if ( profile_type == PROFILES.ABACUS and action and resource_type and authorization_backend.is_authorized( action=action, resource_id=0, resource_type=resource_type, resource_getter=ForwardKwargsGetter(), identity_uuid='', ) ): return _execute_with_obscured_flag(func, args, kwargs) # TODO: remove this bypass logic TAP-2440 if profile_type == PROFILES.COLLABORATORS: return func(*args, **kwargs) if bypass and profile_type: return _execute_with_obscured_flag(func, args, kwargs, obscure_pii=True) return error_response return wrapper return decorator def check_jwt_identity(identity_list: list[str]) -> Callable: """Decorator function which checks if the identity of the user making the request matches the expected identities for the endpoint. .. code-block:: python @app.route("/two") @check_jwt_identity([ "e7b019ea-1829-47f2-9642-cb4647065949", "f9435d76-221d-4570-ab2f-2f0e887c593f" ]) def index(): return "Ok!" :param identity_list: Identities, of user(s) allowed to access the endpoint """ def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): if config.ENVIRONMENT == config.DEV_ENVIRONMENT: logger.warning( f'Disabled check_jwt_identity for development environment.' ) return _execute_with_obscured_flag( func, args, kwargs, obscure_pii=False ) # temporary way to pass permissions check for TS lambdas # until m2m authorization toolkit will be implemented there if is_abacus_tap_bypass_pdp_check_enabled(): logger.info(f'Disabled by feature flag abacus_tap_bypass_pdp_check.') return _execute_with_obscured_flag( func, args, kwargs, obscure_pii=False ) try: # we already have jwt_identity in context because it was already # authorized in before_request hook mechanism using m2m machinery jwt_identity = g.request_context.jwt_identity_id if not jwt_identity: raise JWTAuthError(ERROR_NO_VALID_IDENTITY_IN_CONTEXT) if jwt_identity not in identity_list: raise JWTAuthError( ERROR_NOT_PERMITTED_IDENTITY.format(jwt_identity=jwt_identity) ) except JWTAuthError as auth_error: return ( {'error': auth_error.message}, HTTPStatus.UNAUTHORIZED, ) return _execute_with_obscured_flag(func, args, kwargs, obscure_pii=False) return wrapper return decorator class ParamsContainer: def __init__(self, *args, obscure_pii: bool = False, **kwargs): self.obscure_pii = obscure_pii self.args = args self.kwargs = kwargs def or_( *decorators: Callable, wrap_response: bool = False, call_decorated_function: bool = False, ) -> Any: """ Decorator to combine other permissions decorators in `or` manner . .. code-block:: python @or_( check_jwt_identity([Config.BANK_DETAILS_SERVICE_IDENTITY]), check_access( resource_type=PERMISSIONS_RESOURCE_TYPES.TAX_INFO, action=PERMISSIONS_ACTIONS.EDIT ) ) def some_view(): ... :params decorators: The decorators to use """ def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): errors = set() successful_decorator = None for decorator_func in decorators: decorated = decorator_func(ParamsContainer) result = decorated(*args, **kwargs) if isinstance(result, ParamsContainer): successful_decorator = decorator_func break errors.add(result[0]['error']) if successful_decorator: decorated_func = ( successful_decorator(func) if call_decorated_function else func ) if has_obscure_pii(func): modified_kwargs = dict(kwargs) modified_kwargs['obscure_pii'] = result.obscure_pii else: modified_kwargs = kwargs return decorated_func(*args, **modified_kwargs) if wrap_response: return response.Response( message={'error': ', '.join(sorted(errors))}, status=HTTPStatus.UNAUTHORIZED, ) return ( {'error': ', '.join(sorted(errors))}, HTTPStatus.UNAUTHORIZED, ) return wrapper return decorator def check_record_owner(field_name: str = 'account_payee_id'): """Check record owner decorator.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): valid = False if payee := kwargs.get('payee'): id_ = payee.payoneer_client_reference_id if isinstance(payee, Payee): # TODO: Implement this for Payees once payee operations # happen outside the Abacus context. valid = True if isinstance(payee, AccountPayee): valid = validate_multiple_record_owner([id_])[id_] else: id_ = kwargs[field_name] valid = validate_multiple_record_owner([id_])[id_] if not valid: return ( {'error': ERROR_DONT_HAVE_PERMISSIONS}, HTTPStatus.UNAUTHORIZED, ) return func(*args, **kwargs) return wrapper return decorator