"""Access Control. The access control layer defines who can access to specific parts of our API. It works by checking for each condition if the context matches. Only one condition needs to be true to grant access. In case none of the condition is true, the access is denied and returns a ``401 HTTP status``. Only one condition needs to be true to grant access. Here's a quick example of a route that accesses the endpoint ``/me/``, available to the public:: @api.route('/me/', access=[ access.allow_public_access]) Warning: The access control mechanisms are only available on the route method of the API. The deeper layers should not be concerned about who can access what feature. """ from ddtrace import tracer from jwtauth.utils import jwt_auth_from_environment from grass import config from grass.consts import headers as header_constants from grass.logic import api_token, auth, endpoint_rules, session, user from grass.models import oa_user, profile_user from grass.models import session as model_session from grass.utils import headers class ExceptionAccessList(Exception): """Runtime exception when the access list has not been set properly.""" pass @tracer.wrap() def confirm(handler, access_list=None): """Confirm the endpoint can be accessed. Args: handler (RequestHandler): the request handler. access_list (list): List of all possible access. Raises: ExceptionAccessList: if the access list is not properly formated. Returns: `bool`: if the access is allowed or denied. """ access_list = access_list or [] if not isinstance(access_list, list): raise ExceptionAccessList('The access list is not valid. Allowed: list only.') # We want to return True for OPTIONS requests as these are just # used by CORS preflight checks. # Actual CORS enforcement is handled by haproxy plugin request = handler.request if request.method == 'OPTIONS': return True for callback in access_list: if callback(handler): return True return False def allow_client(*allowed_clients): """Allow client access. This ACL allows any client to perform an action on the behalf of a user. For the first version, the url should always contain 3 fields: client, token and user. This method can be used for generic endpoint that do not require specific user permission (e.g: access user information.) For more role specific endpoint, use the `allow_role` method. Note: This approach will add an extra bit of latency (one additional request) in favor of data security. Args: allowed_clients (list): If present, the route will be restricted to a specific list of clients. """ allowed_clients = allowed_clients or [] @tracer.wrap('grass.access.allow_client.confirm') def confirm(handler): client_id = handler.get_argument('client', default='') token_id = handler.get_argument('token', default='') user_information = handler.get_argument('user', default='') if not user_information: return False namespace, user_id = user_information.split(':') if not allowed_clients or client_id in allowed_clients: try: is_valid = api_token.is_token_valid(token_id, client_id, user_id) if is_valid: request_referer = handler.request.headers.get('Referer', 'Unknown') handler.logger.warning( 'allow_client is being called where ' f'path={handler.request.method}{handler.request.uri} referer={request_referer}' # noqa: E501 ) return is_valid except: return False return False return confirm def allow_role(*roles): """Allow role access. This ACL method allows clients with user permissions to access specific endpoints on their behalf. For instance: a user has granted the client to access all their releases. Examples: .. code:: python @api.route('/home//', access=[access.allow_roles(RELEASE_ROLE)]) def homepage(): return dosomething() Note If some roles are client specific, those role names should be scoped out for those specific clients. """ @tracer.wrap('grass.access.allow_role.confirm') def confirm(handler): return False return confirm def allow_session_access_for_routes( route_yml, environments=[config.QA_ENVIRONMENT, config.PROD_ENVIRONMENT] ): """Allow session access for user for this specific route. Verifies that the url+method that is allowed to this user. All the rules are mentioned in route_yml. Args: route_yml (str): Path to yaml file with route rules. If the proxied service implements its own rules check this can be set to None. environments (list): Environments to check access. Returns: callable: the confirm method that will handle the request. """ # todo(jpenner): remove route_yml once no services.py method is using it if route_yml is None: rules_validator = None else: rules_validator = endpoint_rules.EndpointRulesValidator(route_yml) @tracer.wrap('grass.access.allow_session_access_for_routes.confirm') def confirm(handler): """Check if a session is allowed. Args: handler (``BaseHandler``): The request handler. Returns: bool: True if the session is allowed. """ request = handler.request request_referer = handler.request.headers.get('Referer', 'Unknown') session_token = handler.request.headers.get('session') if not session_token: handler.logger.warning( 'Invalid access, session token missing in request headers. ' f'Referer: {request_referer} URL: {handler.request.method} {handler.request.uri}' # noqa: E501 ) return False token, status = session.get_token(session_token) # Return false if session or status are not valid if not token or status != 200: handler.logger.warning( 'Invalid access, session token not valid. ' f'Referer: {request_referer} URL: {handler.request.method} {handler.request.uri}' # noqa: E501 ) return False user_id = token.get('user_id') handler.current_user = user.get_user_by_id(user_id).message # Check rules only for the configured environments if rules_validator is None or config.environment not in environments: roles_by_name = token.get('roles_by_name', []) handler.current_user.set_roles(roles_by_name) orchard_identity_id = token.get('auth0_user_id') orchard_identity_uuid = token.get('identity_uuid') handler.current_user.set_identity_uuid(orchard_identity_uuid) if orchard_identity_id: handler.current_user.set_identity_id(orchard_identity_id) return True # Check resource rules list user_type = 'alw' if user_id.startswith('alw') else 'oa' if user_type == 'alw': # only for alw users for now. user_roles = token.get('roles', []) if not rules_validator.has_access( request.path, request.method, user_type, user_roles ): handler.logger.warning( f'RULES FAILED for user_roles={user_roles} path={handler.request.method}{handler.request.uri} token={session_token} ' # noqa: E501 f'referer={request_referer}' ) return False orchard_identity_id = token.get('auth0_user_id') orchard_identity_uuid = token.get('identity_uuid') handler.current_user.set_identity_uuid(orchard_identity_uuid) if orchard_identity_id: handler.current_user.set_identity_id(orchard_identity_id) else: orchard_identity_id = token.get('identity_uuid') if orchard_identity_id: handler.current_user.set_identity_id(orchard_identity_id) handler.current_user.set_user_profiles(user_id) if not rules_validator.has_resource_access( request.path, request.method, user_type, user_id ): handler.logger.warning( f'RULES FAILED for path={handler.request.method}{handler.request.uri} token={session_token} ' # noqa: E501 f'referer={request_referer}' ) return False roles_by_name = token.get('roles_by_name', []) handler.current_user.set_roles(roles_by_name) return True return confirm def allow_auth0_m2m_token(): """Allow an API endpoint to acceptan Auth0 M2M Access Token. Verifies JWT is valid and has orchard_identity_id. Also if profile headers are present they are verified that the values match profiles in JWT. This will block any JWT trying to access profiles other than their own. Returns: callable: the confirm method that will handle the request """ @tracer.wrap('grass.access.allow_auth0_m2m_token.confirm') def confirm(handler): """Check access token validity. Args: handler (``BaseHandler``): The request handler. Returns: bool: True if the session is allowed. """ token = headers.extract_authorization_token( handler.request.headers.get(header_constants.AUTHORIZATION) ) request_referer = handler.request.headers.get('Referer', 'Unknown') if not token: handler.logger.warning( f'Missing Authorization header' f'referer={request_referer}' ) return False message, status, payload = auth.validate_auth0_token( token, [config.AUTH0_API_AUDIENCE] ) if not payload: handler.logger.warning( f'INVALID JWT error: {message} , payload: {payload} ' f'referer={request_referer}' ) return False if model_session.is_jwt_cached(token, request_referer): return False m2m_profile_type = payload.get('https://grass.theorchard.com/m2m_profile_type') m2m_profile_id = payload.get('https://grass.theorchard.com/m2m_profile_id') if not m2m_profile_type or not m2m_profile_id: handler.logger.warning( f'Missing either m2m_profile_type or m2m_profile_id' f'referer={request_referer}' ) return False if m2m_profile_type == 'OrchAdminProfile': handler.current_user = oa_user.get_oa_user_by_id( f'{m2m_profile_id}', ).message else: handler.logger.warning( f'Unknown m2m_profile_type: {m2m_profile_type}' f'referer={request_referer}' ) return False return True return confirm def allow_m2m_token(): """Allow an API endpoint to accept an M2M Token. Returns: bool: True if the jwt is valid. """ @tracer.wrap('grass.access.allow_m2m_token.confirm') def confirm(handler): """Check access token validity. Args: handler (``BaseHandler``): The request handler. Returns: bool: True if the session is allowed. """ message = None payload = None token = headers.extract_authorization_token( handler.request.headers.get(header_constants.AUTHORIZATION) ) request_referer = handler.request.headers.get('Referer', 'Unknown') if not token: handler.logger.warning( f'Missing m2m Authorization header' f' referer={request_referer}' ) return False message, status, payload = auth.validate_auth0_token( token, [config.M2M_API_AUDIENCE] ) if message: handler.logger.warning( f'INVALID JWT error: {message} , payload: {payload} ' f'referer={request_referer}' ) return False return True return confirm def allow_jwt_token(): """Allow an API endpoint to accept Bearer JWT Token. Verifies JWT is valid and has orchard_identity_id. Also if profile headers are present they are verified that the values match profiles in JWT. This will block any JWT trying to access profiles other than their own. Returns: callable: the confirm method that will handle the request """ @tracer.wrap('grass.access.allow_jwt_token.confirm') def confirm(handler): """Check access token validity. Args: handler (``BaseHandler``): The request handler. Returns: bool: True if the session is allowed. """ token = headers.extract_authorization_token( handler.request.headers.get(header_constants.AUTHORIZATION) ) if not token: return False message, status, payload = auth.validate_auth0_token( token, [config.AUTH0_API_AUDIENCE] ) request_referer = handler.request.headers.get('Referer', 'Unknown') if not payload: handler.logger.warning( f'INVALID JWT error: {message} , payload: {payload} ' f'referer={request_referer}' ) return False if model_session.is_jwt_cached(token, request_referer): return False try: orchard_identity_id = headers.get_orchard_identity_id(payload) orchard_identity_uuid = headers.get_orchard_identity_uuid(payload) if not orchard_identity_uuid: handler.logger.warning( f'User Missing orchardIdentityId in JWT.Payload: {payload}' f' referer={request_referer}' ) else: orchard_identity_id = orchard_identity_uuid except Exception as err: handler.logger.warning(err) return False user_obj = profile_user.get_user_by_identity( orchard_identity_id, orchard_identity_uuid ) # verify profile headers with JWT payload, if they exist. profile_type = handler.request.headers.get( header_constants.ORCHARD_PROFILE_TYPE ) profile_id = handler.request.headers.get(header_constants.ORCHARD_PROFILE_ID) profile_uuid = handler.request.headers.get( header_constants.ORCHARD_PROFILE_UUID ) # when we only have profile_uuid if profile_uuid: user_obj.has_active_profile = True if not headers.profile_uuid_exist_in_payload(payload, profile_uuid): handler.logger.warning( f'Profile UUID: {profile_uuid} not allowed for ' f'this JWT user.' ) return False user_obj.set_roles_for_profile_uuid(payload, profile_uuid) # when we only have profile_id+profile_type in header elif profile_type and profile_id: user_obj.has_active_profile = True if not headers.profile_exist_in_payload(payload, profile_type, profile_id): handler.logger.warning( f'Profile {profile_type} {profile_id} not allowed for ' f'this JWT user.' ) return False # set current user profile roles user_obj.set_roles(payload, profile_id, profile_type) # set current user id. handler.current_user = user_obj return True return confirm @tracer.wrap() def allow_public_access(handler): """Allow an API endpoint to be visible to the public. An endpoint visible to the public can be used in javascript to fetch model values without performing a query to the backend server. To make sure those endpoints are not abused: a check should be added and a flag should be present in the user cookies. Args: handler (``BaseHandler``): The request handler. Returns: `bool`: if the endpoint can be accessed by the requester. """ return True