Skip to content

Commit

Permalink
chore: add authz external dependency example
Browse files Browse the repository at this point in the history
  • Loading branch information
v-rocheleau committed Oct 28, 2024
1 parent cc8bbe2 commit 897158e
Show file tree
Hide file tree
Showing 11 changed files with 130 additions and 52 deletions.
10 changes: 5 additions & 5 deletions etc/bento.authz.module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
93 changes: 93 additions & 0 deletions etc/example-dep/extra-dep.authz.module.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions etc/example-dep/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
opa-python-client==2.0.0
40 changes: 13 additions & 27 deletions etc/example.authz.module.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
"""


Expand Down Expand Up @@ -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):
"""
Expand All @@ -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)
2 changes: 1 addition & 1 deletion run.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion run.dev.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 11 additions & 11 deletions transcriptomics_data_service/authz/middleware_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 0 additions & 2 deletions transcriptomics_data_service/authz/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions transcriptomics_data_service/routers/experiment_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion transcriptomics_data_service/routers/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 2 additions & 2 deletions transcriptomics_data_service/routers/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 897158e

Please sign in to comment.