"""Flask Request Util. This module makes it easier for flask based microservices to create requests by calling one simple method `setup`. For instance: from flask import Flask from owsrequest import flask_request app = Flask(__name__) flask_request.setup(app, QA_ENVIRONMENT) After, anywhere in your application, you can write: from owsrequest import request request.get('GET', 'service-name', '/hello') """ from functools import partial from functools import wraps import re from typing import Optional from flask import current_app from flask import g from flask import has_app_context from flask import request as current_request from jwtauth import JWTAuth from jwtauth.utils import jwt_auth_from_environment from owsresponse import response from owsresponse.adaptors.flask import flaskify from requests.structures import CaseInsensitiveDict from owsrequest import access from owsrequest import auth from owsrequest import config from owsrequest import error_response from owsrequest import request from owsrequest.config import logger from owsrequest.constants import errors from owsrequest.constants import headers from owsrequest.constants.environment import PROD_ENVIRONMENT from owsrequest.constants.environment import QA_ENVIRONMENT from owsrequest.constants.environment import UAT_ENVIRONMENT from owsrequest.context import get_request_context_from_headers from owsrequest.rules import EndpointRulesValidator from owsrequest.rules import route_to_regex METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'DELETE'] class Ows: """Ows Objects.""" pass def setup( application, environment, add_request_context=False, label_profile=False, verify_access=False, rules_file=None, access_log_only=True, auth_header_required=False, exclude_paths=config.HEALTH_CHECK_ENDPOINTS, uwsgi_cache_enabled=False): """Initialized the request shortcuts. Those requests are attaching themselves directly to the owsrequest.request module. The goal of this update is to make it easier to use within an application. Args: application (Flask): flask application. environment (str): name of the environment. add_request_context (bool): always add request_context to flask.g. label_profile (bool): if True, treat Orchard-User-Id as profile id on RequestContext. verify_access (bool): verify access based on the rules file. rules_file (str): path to the access rules file. access_log_only (bool): only log access errors without blocking. auth_header_required (bool): block request if authorization header is missing exclude_paths (list): list of paths to exclude from the access check. uwsgi_cache_enabled (bool): if True, allow caching in uwsgi. """ jwks = auth.get_auth0_jwks() if environment in {QA_ENVIRONMENT, PROD_ENVIRONMENT, UAT_ENVIRONMENT} and uwsgi_cache_enabled: auth.initialize_cache(application.name) auth.cache_jwks(jwks) for method in METHODS: setattr(request, method.lower(), partial( process, application.name, environment, method.upper(), uwsgi_cache_enabled=uwsgi_cache_enabled)) application.before_request( partial(make_headers_case_insensitive, current_request)) application.before_request(partial(authorize_request, environment, auth_header_required)) if add_request_context: application.add_request_context_to_g = partial( add_request_context_to_g, label_profile) application.before_request(application.add_request_context_to_g) if verify_access and rules_file: rules_validator = EndpointRulesValidator(rules_file) application.before_request( partial(verify_rules_access, current_request, rules_validator, access_log_only, exclude_paths)) def make_headers_case_insensitive(current_request): """Convert request headers to a case insensitive dict.""" current_request.headers = CaseInsensitiveDict(current_request.headers) def verify_rules_access( current_request, validator, log_only=True, exclude_paths=[]): """Verify access to the endpoint with YML rules file. Args: current_request (request): flask request proxy validator (EndpointRulesValidator): rules validator object log_only (bool): only log access errors without blocking exclude_paths (list): list of paths to exclude from the access check Returns: response: error response if rules validation fails """ # Skip roles check for health endpoints if exclude_paths and any([ re.compile(route_to_regex(r)).match(current_request.path) for r in exclude_paths ]): return # Check access rules for frontend->ows-grass and graphql-product requests. context = _request_context() if not context.requestor_service_name or not any([ re.compile(route_to_regex(r)).match(context.requestor_service_name) for r in config.ACCESS_CONTROL_SERVICES ]): g.ows.log.debug( errors.SKIP_ROLE_CHECK_MESSAGE.format( context.requestor_service_name)) return has_access = validator.has_access( path=current_request.path, method=current_request.method, profile=context.profile_type, roles=context.roles ) if not has_access: g.ows.log.info(errors.ACCESS_DENIED_MESSAGE.format( method=current_request.method, path=current_request.path, profile_id=context.profile_id, profile_type=context.profile_type, roles=context.roles, correlation_id=current_request.headers.get('Correlation-Id', '') )) if not log_only: return flaskify(error_response.create_error_forbidden()) def set_rules_validator(application, rules_file: str) -> None: """Load rules file as an EndpointRulesValidator into the app context.""" rules_validator = EndpointRulesValidator(rules_file) application.rules_validator = rules_validator def get_rules_validator() -> Optional[EndpointRulesValidator]: """Get the EndpointRulesValidator from current_app.""" assert has_app_context() if not hasattr(current_app, 'rules_validator'): return None return current_app.rules_validator def get_jwt_auth_client() -> JWTAuth: """Get/Set the jwt auth client on the current_app.""" assert has_app_context() if not hasattr(current_app, 'jwt_auth_client'): jwt_auth_client = jwt_auth_from_environment(config.ENVIRONMENT) current_app.jwt_auth_client = jwt_auth_client return current_app.jwt_auth_client def get_ows(): """Get the ows object on the global. This is very useful when we want to keep a copy of all the different ows specific attributes set on flask.g. Returns: Ows: the ows namespace. """ if not hasattr(g, 'ows'): g.ows = Ows() return g.ows def authorize_request(environment, auth_header_required=False): """Authorize an incoming request. Note: if this method returns a value, flask immediately stops the process and returns. When a value is provided, flask consumes this value and returns it immediately. Args: environment (str): the environment. Returns: Response: optional response returned if the request is not authorized. If the request is authorized no value is returned. """ authorization = current_request.headers.get('Authorization', '') correlation_id = current_request.headers.get('Correlation-Id', '') user_id = current_request.headers.get('Orchard-User-Id', '') token = auth.extract_jwt_token(authorization) # Until we get all microservice to use this security mechanism, we can't # enable it for requests that do not yet provide the authorization. if authorization: if token: jwt_auth_client = get_jwt_auth_client() authorization_response = auth.validate_and_decode_jwt_token( token, jwt_auth_client=jwt_auth_client) else: authorization_response = request.confirm_authorization( environment, authorization, correlation_id, current_request.method, current_request.get_data(), current_request.full_path) # Logging for all environments that something went wrong for this # specific request. if not authorization_response: g.ows.log.warning( errors.UNAUTHORIZED_LOGGLY_MESSAGE.format( environment=environment, user_id=user_id, correlation_id=correlation_id, error_status=authorization_response.status, error_code=authorization_response.errors.get('code'), error_message=authorization_response.errors.get('message')) ) if not authorization_response and\ environment in [PROD_ENVIRONMENT, QA_ENVIRONMENT, UAT_ENVIRONMENT]: return flaskify(authorization_response) else: request_url = current_request.url if environment in {PROD_ENVIRONMENT, QA_ENVIRONMENT, UAT_ENVIRONMENT} \ and not any(url in request_url for url in config.HEALTH_CHECK_ENDPOINTS): if auth_header_required: return flaskify(error_response.create_error_authorization_header_not_found()) g.ows.log.debug( errors.NO_AUTHORIZATION_MESSAGE.format( request_method=current_request.method, request_body=current_request.get_data(), request_url=request_url, environment=environment, user_id=user_id, correlation_id=correlation_id) ) def process( application, environment, method, service_name, path, islegacy=False, uwsgi_cache_enabled=False, **options): """Create a Request. Proxy util to the request.process method which consumes information from flask global (such as the “request”). Args: application (str): name of the current application. environment (str): the current environment. method (str): the http method (POST, GET, PUT, HEAD, OPTIONS). service_name (str): the service which will receive the request. path (str): the path the service is going to call. options (dict): additional options to pass to the request. uwsgi_cache_enabled (bool): if True, allow caching in uwsgi. islegacy (bool): True for php legacy service. Returns: Response: the Requests response. """ correlation_id = next_correlation_id(options.pop('correlation_id', None)) request_auth = None if has_app_context(): if g.request_context.authorization: request_auth = g.request_context.authorization else: logger.warning( f'Failed to get app context while sending request from ' f'{application} to {service_name}') return request.process( application, environment, method, service_name, path, correlation_id, uwsgi_cache_enabled=uwsgi_cache_enabled, authorization_header=request_auth, islegacy=islegacy, **options) def next_correlation_id(correlation_id=None): """Get the correlation id. The correlation id is automatically incremented if available and if no correlation id have been provided directly. Args: correlation_id (optional, str): an existing correlation id, returned if provided. Returns: str: the correlation id. """ if correlation_id: return str(correlation_id) ows = get_ows() assert hasattr(ows, 'correlation_id'), ( 'All flask applications should have a correlation id. Please use ' 'python-owslogger to get the right behavior.') if not hasattr(ows, 'request_counter'): g.ows.request_counter = 0 g.ows.request_counter = g.ows.request_counter + 1 return '{}.{}'.format(g.ows.correlation_id, g.ows.request_counter) def get_grass_headers(request): """Helper for extracted the grass headers from a flask request. Args: request (Flask.request): the request. Returns: tuple: contains grass account type and grass account id. """ header_account_type = request.headers.get(headers.GRASS_ACCOUNT_TYPE) header_account_id = request.headers.get(headers.GRASS_ACCOUNT_ID) return (header_account_type, header_account_id) def verify_grass_headers(request, required=False): """Verify grass request headers from a Flask Request. Verify that grass headers are present, if required. Verify that grass headers are complete, if given. Args: request (Flask.request): the request. required (bool): True if grass requests are required. Returns: response.Response: describes whether headers are valid. """ header_account_type, header_account_id = get_grass_headers(request) header_check_response = access.verify_grass_headers( header_account_type, header_account_id, required=required) return header_check_response def verify_grass_access(request, required=False, **kwargs): """Verify grass request headers and compare to the values passed as kwargs. Verify that grass headers are present, if required. Verify that grass headers are complete, if given. Verify that grass headers do not contradict values from kwargs. Sample usage with kwargs: @app.route('//subaccounts) def do_something_for_route: access.verify_grass_access( request, required=True, vendor=vendor_id) Args: request (Flask.request): the request. required (bool): True if grass requests are required. kwargs (dict): additional validation information. Returns: response.Response: describes whether access is valid. """ header_account_type, header_account_id = get_grass_headers(request) access_check_response = access.verify_grass_access( header_account_type, header_account_id, required=required, **kwargs) return access_check_response def verify_grass_ownership(request, method, *args, **kwargs): """Verify grass ownership of a request. Based on an incoming request, this method will extract all the information from the headers and pass them down to a method that takes two params: `account_type` and `account_id`. For instance: to verify the ownership of a product, you would have a method in your logic: is_product_owner(product_id, account_type=None, account_id=None) And to call this from your handler: ownership = flask_request.verify_grass_ownership( request, logic.is_product_owner, product_id) if not ownership: return ownership Args: request (Flask.request): the request. method (callable): the method to call with the args and kwargs. args (list): list of params to provide to the method that is being called. kwargs (dict): additional set of data to provide to the method to call. Returns: Response: owernship information. """ account_type, account_id = get_grass_headers(request) if any([account_type, account_id]): return method( *args, account_type=account_type, account_id=account_id, **kwargs) return response.Response() def verify_rules_access_standalone( current_request, rules_validator: EndpointRulesValidator = None, ) -> bool: """Utilize verify_rules_access as a standalone function. If rules_validator is not passed in, try to load from app context. If rules_validator is not available from app context, treat access verification as a failure. Args: current_request (flask.request) rules_validator (EndpointRulesValidator) Use this to switch over from `before_request` pattern usage of verify_rules_access. """ if not rules_validator: rules_validator = get_rules_validator() if not rules_validator: return False decision = verify_rules_access(current_request, rules_validator, log_only=False) if decision and hasattr(decision, 'status_code') and decision.status_code == 403: return False return True def get_profile_headers(flask_request): """Helper to extract profile type and id headers from a flask request. Args: flask_request (Flask.request): the request. Returns: tuple: contains orchard profile type and profile id. """ profile_type = flask_request.headers.get(headers.ORCHARD_PROFILE_TYPE) profile_id = flask_request.headers.get(headers.ORCHARD_PROFILE_ID) return profile_type, profile_id def verify_profile_headers(flask_request): """Verify that both the profile headers are present or none at all. 200 response if both the profile headers are present or none at all. 400 response for partial headers. This DOES NOT do a graph lookup to verify if the combination is valid. Args: flask_request (Flask.request): the request. Returns: response.Response: success if both are present or none of them, or 400 status error response. """ profile_type, profile_id = get_profile_headers(flask_request) return access.verify_profile_headers_are_complete(profile_type, profile_id) def verify_profile_headers_match_route( flask_request, profile_type_in_route, profile_id_in_route): """Verify profile headers match profile values in route url. 200 response if no profile headers are present, as this is a backend call. 400 response for partial profile headers. 403 response when header values don't match route param values. This DOES NOT do a graph lookup to verify if the combination is valid. Args: flask_request (Flask.request): the request. profile_type_in_route (str): profile type value in url. profile_id_in_route (str): profile id value in url. profile_id_in_route (str): profile id value in url. Returns: response.Response: describes whether access is valid. """ header_profile_type, header_profile_id = get_profile_headers(flask_request) return access.verify_profile_headers_match_route( header_profile_type, header_profile_id, profile_type_in_route, profile_id_in_route) def request_context_from_headers(**header_args): """Route decorator to determine request context from headers.""" def inner_wrapper(orig_func): @wraps(orig_func) def decorator_request_context_from_headers(*args, **kwargs): g.request_context = get_request_context_from_headers( current_request.headers, header_args) return orig_func(*args, **kwargs) return decorator_request_context_from_headers return inner_wrapper def add_request_context_to_g(label_profile=False): """Method to add request_context to Flask's global g. Needed as its own func to have access to current_request variable. Args: label_profile (bool): if True, treat Orchard-User-Id as profile id Returns: None """ g.request_context = get_request_context_from_headers( current_request.headers, label_profile, get_jwt_auth_client()) def get_ows_headers(): """Get headers ready for ows calls.""" context = _request_context() if context.context_type == 'profile': headers = {} if context.identity_id: headers['Orchard-Identity-Id'] = context.identity_id if context.profile_id: headers['Orchard-Profile-Id'] = context.profile_id if context.identity_uuid: headers['Orchard-Identity-UUID'] = context.identity_uuid if context.profile_type: headers['Orchard-Profile-Type'] = context.profile_type return headers if context.context_type == 'account': headers = {} if context.orchard_user_id: headers['Orchard-User-Id'] = context.orchard_user_id if context.profile_type: headers['Grass-Account-Type'] = context.profile_type if context.profile_id: headers['Grass-Account-Id'] = context.profile_id return headers return {} def _request_context(): return g.request_context