diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000000000000000000000000000000000..dacbe64483886a6df67119f059914ae26a367656 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 99 +exclude = venv,env,.tox,*/migrations/*,*/frontend/*,build/*,.venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..61296f1ceb1ecfdab53a2419900a3f188429fb6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +*.sqlite3 + +# IDE files +.idea +.vscode + +# Secrets +secrets.env + +# mac temp files +.DS_store diff --git a/apigatewayauth/api_gateway_auth.py b/apigatewayauth/api_gateway_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..f99f69d12a1317ee96913433bcea658d403275cc --- /dev/null +++ b/apigatewayauth/api_gateway_auth.py @@ -0,0 +1,56 @@ +from typing import Optional, Set +from dataclasses import dataclass +from rest_framework.request import Request +from identitylib.identifiers import Identifier + +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + + +@dataclass(eq=True) +class APIGatewayAuthenticationDetails: + """ + A dataclass representing the authentication information passed from the API Gateway. + + """ + + principal_identifier: Identifier + scopes: Set[str] + app_id: Optional[str] = None + client_id: Optional[str] = None + + +class APIGatewayAuthentication(authentication.BaseAuthentication): + """ + An Authentication provider which interprets the headers provided by the API Gateway. + + The Card API expects to only be deployed behind the API Gateway, and therefore relies on + the fact that the headers provided are authoritative. + + """ + + def authenticate(self, request: Request): + if not request.META.get('HTTP_X_API_ORG_NAME', None): + # bail early if we look like we're not being called by the API Gateway + return None + if not request.META.get('HTTP_X_API_OAUTH2_USER', None): + raise AuthenticationFailed('Could not authenticate using x-api-* headers') + + try: + principal_identifier = Identifier.from_string( + request.META['HTTP_X_API_OAUTH2_USER'], + find_by_alias=True + ) + except Exception: + raise AuthenticationFailed('Invalid principal identifier') + + auth = APIGatewayAuthenticationDetails( + principal_identifier=principal_identifier, + scopes=set(filter(bool, request.META.get('HTTP_X_API_OAUTH2_SCOPE', '').split(' '))), + # the following will only be populated for confidential clients + app_id=request.META.get('HTTP_X_API_DEVELOPER_APP_ID', None), + client_id=request.META.get('HTTP_X_API_OAUTH2_CLIENT_ID', None) + ) + # the first item in the tuple represents the 'user' which we don't have when we've + # used the API Gateway for authentication. + return None, auth diff --git a/apigatewayauth/openapi.py b/apigatewayauth/openapi.py new file mode 100644 index 0000000000000000000000000000000000000000..eceedde0042781f4e28b4047b38adf9326069c46 --- /dev/null +++ b/apigatewayauth/openapi.py @@ -0,0 +1,37 @@ +from api.scopes import SCOPES_TO_DESCRIPTION + +API_SERVICE_CLIENT_CREDENTIALS = 'API Service OAuth2 Client Credentials' +API_SERVICE_ACCESS_CODE = 'API Service OAuth2 Access Code' + + +SECURITY_DEFINITIONS = { + API_SERVICE_CLIENT_CREDENTIALS: { + 'type': 'oauth2', + 'description': ( + 'Allows authentication using client credentials obtained from the API Service' + ), + 'flow': 'application', # should be `clientCredentials` when we update to OpenApi 3.0 + 'tokenUrl': 'https://<gateway_host>/oauth/client_credential/accesstoken' + '?grant_type=client_credentials', + 'scopes': SCOPES_TO_DESCRIPTION + }, + API_SERVICE_ACCESS_CODE: { + 'type': 'oauth2', + 'flow': 'accessCode', + 'authorizationUrl': 'https://<gateway_host>/oauth2/v1/auth', + 'tokenUrl': 'https://<gateway_host>/oauth2/v1/token', + 'scopes': SCOPES_TO_DESCRIPTION + } +} + + +def any_api_service_security_method_with_scopes(*scopes): + """ + Helper method which returns security definitions for any API Service security + method with the given scopes. + + """ + return [ + {security_method: list(scopes)} + for security_method in SECURITY_DEFINITIONS.keys() + ] diff --git a/apigatewayauth/permissions.py b/apigatewayauth/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..88b281702f63eb86212edeeed698e595756dca7b --- /dev/null +++ b/apigatewayauth/permissions.py @@ -0,0 +1,162 @@ +from typing import Set +from django.core.cache import cache +from rest_framework import permissions +from identitylib.identifiers import IdentifierSchemes + +from ucamlookup.utils import get_connection +from ucamlookup.ibisclient import PersonMethods, IbisException + +from logging import getLogger + +from .api_gateway_auth import APIGatewayAuthenticationDetails +from .permissions_spec import get_groups_with_permission, get_principals_with_permission + + +LOG = getLogger(__name__) + + +class Disallowed(permissions.BasePermission): + """ + A permissions class which disallows all access, this is used as the default permissions + class to stop routes being added which accidentally expose data. + + """ + def has_permission(self, request, view): + return False + + def has_object_permission(self, request, view, obj): + return False + + +class IsResourceOwningPrincipal(permissions.BasePermission): + """ + A permissions class which ensures that the client represents a principal who is limited + to accessing only resources they own. + + """ + + message = 'Please authenticate as the owning user using the API Gateway.' + + @staticmethod + def get_queryset_for_principal(request, base_object): + """ + A helper method which filters the given object's queryset to the owning principal + if required + + """ + if not getattr(request, 'should_limit_to_resource_owning_principal', False): + return base_object.objects.all() + if not isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails): + return base_object.objects.none() + + if not callable(getattr(base_object, 'get_queryset_for_principal', None)): + raise ValueError( + f'{base_object} does not implement get_queryset_for_principal' + ) + return base_object.get_queryset_for_principal(request.auth.principal_identifier) + + def has_permission(self, request, view): + # we cannot determine permissions ownership permissions on list routes, but + # rely on `get_queryset_for_principal` to be used to filter the queryset + # appropriately + if isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails): + setattr(request, 'should_limit_to_resource_owning_principal', True) + return True + return False + + def has_object_permission(self, request, view, obj): + if not isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails): + return False + + is_owned_by = getattr(obj, 'is_owned_by', None) + if not callable(is_owned_by): + LOG.warn(f'Unable to determine ownership for {obj}') + return False + + return is_owned_by(request.auth.principal_identifier) + + +def HasAnyScope(*required_scopes): + + class HasAnyScopesPermission(permissions.BasePermission): + """ + A permissions class which enforces that the given request has any of the given scopes. + + """ + + message = f'Request must have one of the following scope(s) {" ".join(required_scopes)}' + + def has_permission(self, request, view): + request_scopes = getattr(getattr(request, 'auth', {}), 'scopes', set()) + return len(set(required_scopes) & request_scopes) > 0 + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + return HasAnyScopesPermission + + +def SpecifiedPermission(permission: str): + + class HasSpecifiedPermission(permissions.BasePermission): + """ + A permissions class which ensures that the principal has the correct permissions + within the permissions specification. + + """ + + message = f'Authenticated principal does not have permission {permission}' + + def has_permission(self, request, view): + principals_with_permission = get_principals_with_permission(permission) + if request.auth.principal_identifier in principals_with_permission: + return True + + if request.auth.principal_identifier.scheme != IdentifierSchemes.CRSID: + LOG.warn('Can only determine group membership for principals identified by CRSID') + return + + # special case for people identified by crsid - check whether they are in a + # lookup group within our list of identities for permission + groups_with_permission = get_groups_with_permission(permission) + lookup_group_ids = set([ + identifier.value for identifier in groups_with_permission + if identifier.scheme == IdentifierSchemes.LOOKUP_GROUP + ]) + + if not lookup_group_ids: + return False + + return self.is_in_any_lookup_group( + request.auth.principal_identifier.value, lookup_group_ids + ) + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + def is_in_any_lookup_group(self, crsid: str, group_ids: Set[str]): + """ + Determine whether a person identified by a crsid is a member of any of the lookup + groups provided. Caches the result for 5 minutes to speed up subsequent responses. + + """ + cache_key = f'{crsid}_in_{",".join(group_ids)}' + cached_response = cache.get(cache_key) + if cached_response is not None: + return cached_response + + is_in_group = False + try: + group_list = PersonMethods( + get_connection() + ).getGroups(scheme="crsid", identifier=crsid) + is_in_group = any( + (group.groupid for group in group_list if group.groupid in group_ids) + ) + except IbisException as err: + LOG.warn(f'Failed to get Lookup groups for {crsid} due to {err}') + + cache.set(cache_key, is_in_group, timeout=600) + return is_in_group + + return HasSpecifiedPermission diff --git a/apigatewayauth/permissions_spec.py b/apigatewayauth/permissions_spec.py new file mode 100644 index 0000000000000000000000000000000000000000..9a45147a9670749714537c8811c91128ef76fc63 --- /dev/null +++ b/apigatewayauth/permissions_spec.py @@ -0,0 +1,75 @@ +from typing import List, Dict, Set +from django.core.cache import cache +from django.conf import settings +from rest_framework.request import Request +from yaml import safe_load +from geddit import geddit +from identitylib.identifiers import Identifier + +from .api_gateway_auth import APIGatewayAuthenticationDetails +from .permissions import SpecifiedPermission + + +PERMISSIONS_CACHE_KEY = '__PERMISSION_CACHE__' + + +def get_permission_spec() -> Dict[str, Dict[str, List]]: + """ + Returns the permissions spec - expected to be a mapping of permission name to a list of + identifiers representing people or service identities who have the given permission. + + """ + + cached_entry = cache.get(PERMISSIONS_CACHE_KEY) + if cached_entry: + return cached_entry + + permissions_spec = safe_load(geddit(settings.PERMISSIONS_SPECIFICATION_URL)) + # Cache the permissions spec for 12 hours. We expect the Card API to be redeployed + # when the permissions spec is updated, so it is safe to cache this for a long period + # of time. + cache.set(PERMISSIONS_CACHE_KEY, permissions_spec, timeout=43200) + + return permissions_spec + + +def get_principals_with_permission(permission_name: str) -> Set[Identifier]: + """ + Returns a set of Identifiers reprenting people or service identities which have the + given permission according to the permissions specification. + + """ + return set( + map( + lambda identifier_str: Identifier.from_string(identifier_str, find_by_alias=True), + get_permission_spec().get(permission_name, {}).get('principals', []), + ) + ) + + +def get_groups_with_permission(permission_name: str) -> Set[Identifier]: + """ + Returns a set of Identifiers representing groups which indicate membership gives the + users or service accounts the specified permission. + + """ + return set( + map( + lambda identifier_str: Identifier.from_string(identifier_str, find_by_alias=True), + get_permission_spec().get(permission_name, {}).get('groups', []), + ) + ) + + +def get_permissions_for_request(request: Request): + """ + Returns a list of permissions which the request's principal has been granted. + The permissions will be key any of the keys from the permissions spec, where the + principal is included within the principals or groups sections. + + """ + + return [ + permission_name for permission_name in get_permission_spec().keys() if + SpecifiedPermission(permission_name)().has_permission(request, None) + ] if isinstance(request.auth, APIGatewayAuthenticationDetails) else [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6aebb919f5f9ae1d3ba9ff8b21fd3704da08019c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +django~=3.2.7 +djangorestframework +geddit +PyYAML +ucam-identitylib==1.0.1 +django-ucamlookup~=3.0.5 \ No newline at end of file