From 897158e45dd44d773163469613d1c1693f17da9c Mon Sep 17 00:00:00 2001 From: Victor Rocheleau Date: Mon, 28 Oct 2024 20:09:52 +0000 Subject: [PATCH] chore: add authz external dependency example --- etc/bento.authz.module.py | 10 +- etc/example-dep/extra-dep.authz.module.py | 93 +++++++++++++++++++ etc/example-dep/requirements.txt | 1 + etc/example.authz.module.py | 40 +++----- run.bash | 2 +- run.dev.bash | 2 +- .../authz/middleware_base.py | 22 ++--- transcriptomics_data_service/authz/plugin.py | 2 - .../routers/experiment_results.py | 4 +- .../routers/expressions.py | 2 +- .../routers/ingest.py | 4 +- 11 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 etc/example-dep/extra-dep.authz.module.py create mode 100644 etc/example-dep/requirements.txt diff --git a/etc/bento.authz.module.py b/etc/bento.authz.module.py index 3c512cb..489da6b 100644 --- a/etc/bento.authz.module.py +++ b/etc/bento.authz.module.py @@ -59,23 +59,23 @@ def _dep_perm_data_everything(self, permission: Permission): def dep_authz_ingest(self): # User needs P_INGEST_DATA permission on the target resource (injected) - return self._dep_require_permission_injected_resource(P_INGEST_DATA) + return [self._dep_require_permission_injected_resource(P_INGEST_DATA)] def dep_authz_normalize(self): - return self._dep_require_permission_injected_resource(P_INGEST_DATA) + return [self._dep_require_permission_injected_resource(P_INGEST_DATA)] # EXPERIMENT RESULT router paths def dep_authz_get_experiment_result(self): - return self._dep_require_permission_injected_resource(P_QUERY_DATA) + return [self._dep_require_permission_injected_resource(P_QUERY_DATA)] def dep_authz_delete_experiment_result(self): - return self._dep_require_permission_injected_resource(P_DELETE_DATA) + return [self._dep_require_permission_injected_resource(P_DELETE_DATA)] # EXPRESSIONS router paths def dep_authz_expressions_list(self): - return self._dep_perm_data_everything(P_QUERY_DATA) + return [self._dep_perm_data_everything(P_QUERY_DATA)] authz_middleware = BentoAuthzMiddleware.build_from_fastapi_pydantic_config(config, logger) diff --git a/etc/example-dep/extra-dep.authz.module.py b/etc/example-dep/extra-dep.authz.module.py new file mode 100644 index 0000000..09d71d8 --- /dev/null +++ b/etc/example-dep/extra-dep.authz.module.py @@ -0,0 +1,93 @@ +from logging import Logger +from typing import Annotated, Any, Awaitable, Callable, Coroutine +from fastapi import Depends, FastAPI, HTTPException, Header, Request, Response +from fastapi.responses import JSONResponse + +from transcriptomics_data_service.authz.middleware_base import BaseAuthzMiddleware +from transcriptomics_data_service.config import Config, get_config +from transcriptomics_data_service.logger import get_logger + +config = get_config() +logger = get_logger(config) + + +""" +CUSTOM PLUGIN DEPENDENCY +Extra dependencies can be added if the authz plugin requires them. +In this example, the authz module imports the OPA agent. +Since OPA does not ship with TDS, a requirements.txt file must be placed under 'lib'. +""" + +from opa_client.opa import OpaClient + + +class ApiKeyAuthzMiddleware(BaseAuthzMiddleware): + """ + Concrete implementation of BaseAuthzMiddleware to authorize requests based on the provided API key. + """ + + def __init__(self, config: Config, logger: Logger) -> None: + super().__init__() + self.enabled = config.bento_authz_enabled + self.logger = logger + + # Init the OPA client with the server + self.opa_client = OpaClient(host="opa-container", port=8181) + try: + # Commented out as this is not pointing to a real OPA server + # self.logger.info(self.opa_client.check_connection()) + pass + except: + raise Exception("Could not establish connection to the OPA service.") + + # Middleware lifecycle + + def attach(self, app: FastAPI): + app.middleware("http")(self.dispatch) + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Coroutine[Any, Any, Response]: + if not self.enabled: + return await call_next(request) + + try: + res = await call_next(request) + except HTTPException as e: + # Catch exceptions raised by authz functions + self.logger.error(e) + return JSONResponse(status_code=e.status_code, content=e.detail) + + return res + + # OPA authorization + def _dep_check_opa(self): + async def inner(request: Request): + # Check the permission using the OPA client. + # We assume true for the sake of the demonstration + # authz_result = await self.opa_client.check_permission() + authz_result = True + if not authz_result: + raise HTTPException(status_code=403, detail="Unauthorized: policy evaluation failed") + + return Depends(inner) + + # Authz logic: only check for valid API key + + def dep_authz_ingest(self): + return [self._dep_check_opa()] + + def dep_authz_normalize(self): + return [self._dep_check_opa()] + + def dep_authz_delete_experiment_result(self): + return [self._dep_check_opa()] + + def dep_authz_expressions_list(self): + return [self._dep_check_opa()] + + def dep_authz_get_experiment_result(self): + return [self._dep_check_opa()] + + +authz_middleware = ApiKeyAuthzMiddleware(config, logger) diff --git a/etc/example-dep/requirements.txt b/etc/example-dep/requirements.txt new file mode 100644 index 0000000..4ef31a8 --- /dev/null +++ b/etc/example-dep/requirements.txt @@ -0,0 +1 @@ +opa-python-client==2.0.0 diff --git a/etc/example.authz.module.py b/etc/example.authz.module.py index 6e34cdd..13e7e19 100644 --- a/etc/example.authz.module.py +++ b/etc/example.authz.module.py @@ -1,8 +1,7 @@ from logging import Logger -from typing import Annotated, Any, Awaitable, Callable, Coroutine +from typing import Annotated, Any, Awaitable, Callable, Coroutine, Sequence from fastapi import Depends, FastAPI, HTTPException, Header, Request, Response from fastapi.responses import JSONResponse -from starlette.responses import Response from transcriptomics_data_service.authz.middleware_base import BaseAuthzMiddleware from transcriptomics_data_service.config import Config, get_config @@ -19,7 +18,7 @@ Variables placed there will be loaded as lowercase properties This variable's value can be accessed with: config.api_key -API_KEY = "fake-super-secret-api-key" +API_KEY="fake-super-secret-api-key" """ @@ -60,17 +59,6 @@ async def dispatch( return res # API KEY authorization - def dep_app(self): - """ - API-level dependency injection - Injects a required header for the API key authz: x_api_key - OpenAPI automatically includes it on all paths. - """ - - async def _inner(x_api_key: Annotated[str, Header()]): - return x_api_key - - return [Depends(_inner)] def _dep_check_api_key(self): """ @@ -85,22 +73,20 @@ async def _inner(x_api_key: Annotated[str, Header()]): return Depends(_inner) - # Authz logic: only check for valid API key - - def dep_authz_ingest(self): - return self._dep_check_api_key() - - def dep_authz_normalize(self): - return self._dep_check_api_key() + def dep_ingest_router(self) -> Sequence[Depends]: + # Require API key check on the ingest router + return [self._dep_check_api_key()] - def dep_authz_delete_experiment_result(self): - return self._dep_check_api_key() + def dep_expression_router(self) -> Sequence[Depends]: + # Require API key check on the expressions router + return [self._dep_check_api_key()] - def dep_authz_expressions_list(self): - return self._dep_check_api_key() + def dep_experiment_result_router(self) -> Sequence[Depends]: + # Require API key check on the experiment_result router + return [self._dep_check_api_key()] - def dep_authz_get_experiment_result(self): - return self._dep_check_api_key() + # NOTE: With an all-or-nothing authz mechanism like an API key, + # we can place the authz checks at the router level to have a more concise module. authz_middleware = ApiKeyAuthzMiddleware(config, logger) diff --git a/run.bash b/run.bash index 10b1be2..b88b452 100644 --- a/run.bash +++ b/run.bash @@ -6,7 +6,7 @@ export ASGI_APP="transcriptomics_data_service.main:app" : "${INTERNAL_PORT:=5000}" # Extra dependencies installation for authz plugin -if ! [ -f /tds/lib/requirements.txt ]; then +if [ -f /tds/lib/requirements.txt ]; then pip install -r /tds/lib/requirements.txt fi diff --git a/run.dev.bash b/run.dev.bash index c1122f6..98a4025 100644 --- a/run.dev.bash +++ b/run.dev.bash @@ -4,7 +4,7 @@ /poetry_user_install_dev.bash # Extra dependencies installation for authz plugin -if ! [ -f /tds/lib/requirements.txt ]; then +if [ -f /tds/lib/requirements.txt ]; then pip install -r /tds/lib/requirements.txt fi diff --git a/transcriptomics_data_service/authz/middleware_base.py b/transcriptomics_data_service/authz/middleware_base.py index d42a5c6..51f5120 100644 --- a/transcriptomics_data_service/authz/middleware_base.py +++ b/transcriptomics_data_service/authz/middleware_base.py @@ -30,7 +30,7 @@ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaita """ raise NotImplemented() - async def mark_authz_done(self, request: Request): + def mark_authz_done(self, request: Request): pass # Dependency injections @@ -79,20 +79,20 @@ def dep_experiment_result_router(self) -> None | Sequence[Depends]: ###### INGEST router paths - def dep_authz_ingest(self) -> Depends: - raise NotImplemented() + def dep_authz_ingest(self) -> None | Sequence[Depends]: + return None - def dep_authz_normalize(self) -> Depends: - raise NotImplemented() + def dep_authz_normalize(self) -> None | Sequence[Depends]: + return None ###### EXPRESSIONS router paths - def dep_authz_expressions_list(self) -> Depends: - raise NotImplemented() + def dep_authz_expressions_list(self) -> None | Sequence[Depends]: + return None ###### EXPERIMENT RESULTS router paths - def dep_authz_delete_experiment_result(self) -> Depends: - raise NotImplemented() + def dep_authz_delete_experiment_result(self) -> None | Sequence[Depends]: + return None - def dep_authz_get_experiment_result(self) -> Depends: - raise NotImplemented() + def dep_authz_get_experiment_result(self) -> None | Sequence[Depends]: + return None diff --git a/transcriptomics_data_service/authz/plugin.py b/transcriptomics_data_service/authz/plugin.py index 73a4508..963480f 100644 --- a/transcriptomics_data_service/authz/plugin.py +++ b/transcriptomics_data_service/authz/plugin.py @@ -16,8 +16,6 @@ def import_module_from_path(path): return module -# TODO find a way to allow plugin writers to specify additional dependencies to be installed - AUTHZ_MODULE_PATH = "/tds/lib/authz.module.py" authz_plugin_module = import_module_from_path(AUTHZ_MODULE_PATH) diff --git a/transcriptomics_data_service/routers/experiment_results.py b/transcriptomics_data_service/routers/experiment_results.py index d6a7f4e..f4d825e 100644 --- a/transcriptomics_data_service/routers/experiment_results.py +++ b/transcriptomics_data_service/routers/experiment_results.py @@ -8,11 +8,11 @@ experiment_router = APIRouter(prefix="/experiment", dependencies=authz_plugin.dep_experiment_result_router()) -@experiment_router.delete("/{experiment_result_id}", dependencies=[authz_plugin.dep_authz_delete_experiment_result()]) +@experiment_router.delete("/{experiment_result_id}", dependencies=authz_plugin.dep_authz_delete_experiment_result()) async def delete_experiment_result(db: DatabaseDependency, experiment_result_id: str): await db.delete_experiment_result(experiment_result_id) -@experiment_router.get("/{experiment_result_id}", dependencies=[authz_plugin.dep_authz_get_experiment_result()]) +@experiment_router.get("/{experiment_result_id}", dependencies=authz_plugin.dep_authz_get_experiment_result()) async def get_experiment_result(db: DatabaseDependency, experiment_result_id: str): return await db.read_experiment_result(experiment_result_id) diff --git a/transcriptomics_data_service/routers/expressions.py b/transcriptomics_data_service/routers/expressions.py index cc3a9c8..5b181f6 100644 --- a/transcriptomics_data_service/routers/expressions.py +++ b/transcriptomics_data_service/routers/expressions.py @@ -8,6 +8,6 @@ expression_router = APIRouter(prefix="/expressions", dependencies=authz_plugin.dep_expression_router()) -@expression_router.get("", status_code=status.HTTP_200_OK, dependencies=[authz_plugin.dep_authz_expressions_list()]) +@expression_router.get("", status_code=status.HTTP_200_OK, dependencies=authz_plugin.dep_authz_expressions_list()) async def expressions_list(db: DatabaseDependency): return await db.fetch_expressions() diff --git a/transcriptomics_data_service/routers/ingest.py b/transcriptomics_data_service/routers/ingest.py index 8ad9f6a..312da67 100644 --- a/transcriptomics_data_service/routers/ingest.py +++ b/transcriptomics_data_service/routers/ingest.py @@ -19,7 +19,7 @@ "/ingest/{experiment_result_id}/assembly-name/{assembly_name}/assembly-id/{assembly_id}", status_code=status.HTTP_200_OK, # Injects the plugin authz middleware dep_authorize_ingest function - dependencies=[authz_plugin.dep_authz_ingest()], + dependencies=authz_plugin.dep_authz_ingest(), ) async def ingest( request: Request, @@ -58,7 +58,7 @@ async def ingest( @ingest_router.post( "/normalize/{experiment_result_id}", status_code=status.HTTP_200_OK, - dependencies=[authz_plugin.dep_authz_normalize()], + dependencies=authz_plugin.dep_authz_normalize(), ) async def normalize( db: DatabaseDependency,