From 30a75f191b9ae4d0ddbb72e8501cbc858b089640 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 15 May 2026 10:40:41 +0100 Subject: [PATCH 1/8] WIP opa client Proof of concept opa client with dependency injection and example check --- src/blueapi/config.py | 8 +++ src/blueapi/service/authorization.py | 76 ++++++++++++++++++++++++++++ src/blueapi/service/main.py | 41 ++++++++++++--- 3 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 src/blueapi/service/authorization.py diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 83d6d7021..7ac61e711 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -296,6 +296,12 @@ class Tag(StrEnum): META = "Meta" +class OpaConfig(BlueapiBaseModel): + root: HttpUrl + tiled_service_account_check: str + submit_plan_check: str + + class ApplicationConfig(BlueapiBaseModel): """ Config for the worker application as a whole. Root of @@ -335,6 +341,7 @@ class ApplicationConfig(BlueapiBaseModel): oidc: OIDCConfig | None = None auth_token_path: Path | None = None numtracker: NumtrackerConfig | None = None + opa: OpaConfig | None = None def __eq__(self, other: object) -> bool: if isinstance(other, ApplicationConfig): @@ -343,6 +350,7 @@ def __eq__(self, other: object) -> bool: & (self.env == other.env) & (self.logging == other.logging) & (self.api == other.api) + & (self.opa == other.opa) ) return False diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py new file mode 100644 index 000000000..5403b4bf6 --- /dev/null +++ b/src/blueapi/service/authorization.py @@ -0,0 +1,76 @@ +import logging +import re +from collections.abc import Mapping +from typing import Any + +import aiohttp +from aiohttp import ClientSession +from fastapi import HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +from blueapi.config import OpaConfig +from blueapi.service.model import TaskRequest + +LOGGER = logging.getLogger(__name__) + +INSTRUMENT_SESSION_RE = re.compile(r"^[a-z]{2}(?P\d+)-(?P\d+)$") + + +class OpaClient: + client: aiohttp.ClientSession + + def __init__(self, instrument: str, config: OpaConfig): + LOGGER.info("Creating OpaClient for %s with config %s", instrument, config) + self._instrument = instrument + self._conf = config + self._url = config.root.encoded_string() + self._session = ClientSession(base_url=config.root.encoded_string()) + + def for_token(self, token: str) -> "OpaUserClient": + return OpaUserClient(self, token) + + async def check(self, endpoint: str, data: Mapping[str, Any]): + try: + resp = await self._session.post( + endpoint, + json={"input": {"beamline": self._instrument, **data}}, + ) + if not (await resp.json())["result"]: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) + except Exception as e: + LOGGER.exception("Failed to run check", e) + raise + + async def require_tiled_service_account(self, token: str): + await self.check( + self._conf.tiled_service_account_check, {"token": token} + ) + + async def submit_plan_check(self, token: str, instrument_session: str): + if not (match := INSTRUMENT_SESSION_RE.match(instrument_session)): + raise ValueError("Invalid instrument session") + + await self.check( + self._conf.submit_plan_check, + { + "token": token, + "audience": "account", + "proposal": int(match["proposal"]), + "visit": int(match["visit"]), + }, + ) + + +class OpaUserClient: + client: OpaClient + token: str + + def __init__(self, client: OpaClient, token: str): + self.client = client + self.token = token + + async def check_submit_plan(self, task: TaskRequest): + LOGGER.info("Checking permissions to run task: %s", task) + await self.client.submit_plan_check( + token=self.token, instrument_session=task.instrument_session + ) diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index a53c46885..5637f0683 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -2,8 +2,9 @@ import urllib.parse from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager -from typing import Annotated, Any +from typing import Annotated, Any, cast +from fastapi.security.utils import get_authorization_scheme_param import jwt from fastapi import ( APIRouter, @@ -32,6 +33,7 @@ from pydantic import ValidationError from pydantic.json_schema import SkipJsonSchema from starlette.responses import JSONResponse +from starlette.status import HTTP_401_UNAUTHORIZED from super_state_machine.errors import TransitionError from blueapi import __version__ @@ -56,6 +58,7 @@ TasksListResponse, WorkerTask, ) +from .authorization import OpaClient, OpaUserClient from .runner import WorkerDispatcher RUNNER: WorkerDispatcher | None = None @@ -93,6 +96,8 @@ def lifespan(config: ApplicationConfig): @asynccontextmanager async def inner(app: FastAPI): setup_runner(config) + if config.env.metadata and config.opa: + app.state.authz = OpaClient(config.env.metadata.instrument, config.opa) yield teardown_runner() @@ -140,15 +145,22 @@ def get_app(config: ApplicationConfig): return app +def bearer_token(req: Request) -> str | None: + auth = req.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(auth) + if scheme.casefold() != "bearer": + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return param.strip() + + def decode_access_token(config: OIDCConfig): jwkclient = jwt.PyJWKClient(config.jwks_uri) - oauth_scheme = OAuth2AuthorizationCodeBearer( - authorizationUrl=config.authorization_endpoint, - tokenUrl=config.token_endpoint, - refreshUrl=config.token_endpoint, - ) - def inner(request: Request, access_token: str = Depends(oauth_scheme)): + def inner(request: Request, access_token: Annotated[str, Depends(bearer_token)]): signing_key = jwkclient.get_signing_key_from_jwt(access_token) decoded: dict[str, Any] = jwt.decode( access_token, @@ -166,6 +178,20 @@ def inner(request: Request, access_token: str = Depends(oauth_scheme)): TRACER = get_tracer("interface") +async def opa( + request: Request, token: Annotated[str, Depends(bearer_token)] +) -> OpaUserClient | None: + if client := cast(OpaClient | None, getattr(request.app.state, "authz", None)): + return client.for_token(token) + +async def submit_permission( + task_request: Annotated[TaskRequest, Body()], + opa: Annotated[OpaUserClient, Depends(opa)], +): + if opa: + await opa.check_submit_plan(task_request) + + async def on_key_error_404(_: Request, __: Exception): return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, @@ -292,6 +318,7 @@ def submit_task( response: Response, task_request: Annotated[TaskRequest, Body(..., examples=[example_task_request])], runner: Annotated[WorkerDispatcher, Depends(_runner)], + _: Annotated[None, Depends(submit_permission)], ) -> TaskResponse: """Submit a task to the worker.""" try: From e68c467251d7ef3ee503981d0bce0fa412a95706 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Fri, 15 May 2026 16:29:53 +0100 Subject: [PATCH 2/8] Stop the machines complaining --- src/blueapi/service/authorization.py | 4 +--- src/blueapi/service/main.py | 9 +++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py index 5403b4bf6..9ea72c402 100644 --- a/src/blueapi/service/authorization.py +++ b/src/blueapi/service/authorization.py @@ -42,9 +42,7 @@ async def check(self, endpoint: str, data: Mapping[str, Any]): raise async def require_tiled_service_account(self, token: str): - await self.check( - self._conf.tiled_service_account_check, {"token": token} - ) + await self.check(self._conf.tiled_service_account_check, {"token": token}) async def submit_plan_check(self, token: str, instrument_session: str): if not (match := INSTRUMENT_SESSION_RE.match(instrument_session)): diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 5637f0683..25b0ab47a 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -4,7 +4,6 @@ from contextlib import asynccontextmanager from typing import Annotated, Any, cast -from fastapi.security.utils import get_authorization_scheme_param import jwt from fastapi import ( APIRouter, @@ -20,7 +19,7 @@ from fastapi.datastructures import Address from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, StreamingResponse -from fastapi.security import OAuth2AuthorizationCodeBearer +from fastapi.security.utils import get_authorization_scheme_param from observability_utils.tracing import ( add_span_attributes, get_tracer, @@ -42,6 +41,7 @@ from blueapi.worker import TrackableTask, WorkerState from blueapi.worker.event import TaskStatusEnum +from .authorization import OpaClient, OpaUserClient from .model import ( DeviceModel, DeviceResponse, @@ -58,7 +58,6 @@ TasksListResponse, WorkerTask, ) -from .authorization import OpaClient, OpaUserClient from .runner import WorkerDispatcher RUNNER: WorkerDispatcher | None = None @@ -181,8 +180,10 @@ def inner(request: Request, access_token: Annotated[str, Depends(bearer_token)]) async def opa( request: Request, token: Annotated[str, Depends(bearer_token)] ) -> OpaUserClient | None: - if client := cast(OpaClient | None, getattr(request.app.state, "authz", None)): + if client := cast(OpaClient, getattr(request.app.state, "authz", None)): return client.for_token(token) + return None + async def submit_permission( task_request: Annotated[TaskRequest, Body()], From b6cced2829f85b133cc6e021c19034bcd66e9101 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 18 May 2026 11:34:29 +0100 Subject: [PATCH 3/8] Add aiohttp dependency Package was already a transitive dependency but is now being used instead of adding more uses of httpx. --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 659779994..9f4231c76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "tomlkit", "graypy>=2.1.0", "httpx>=0.28.1", + "aiohttp>=3.13.5", ] dynamic = ["version"] license.file = "LICENSE" diff --git a/uv.lock b/uv.lock index 2ea1869e0..e5414f79c 100644 --- a/uv.lock +++ b/uv.lock @@ -420,6 +420,7 @@ name = "blueapi" source = { editable = "." } dependencies = [ { name = "aioca" }, + { name = "aiohttp" }, { name = "bluesky", extra = ["plotting"] }, { name = "bluesky-stomp" }, { name = "click" }, @@ -481,6 +482,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aioca" }, + { name = "aiohttp", specifier = ">=3.13.5" }, { name = "bluesky", extras = ["plotting"], specifier = ">=1.14.0" }, { name = "bluesky-stomp", specifier = ">=0.1.6" }, { name = "click", specifier = ">=8.2.0" }, From 240042a53e7ea417af56eca03e9cd5960783b8e3 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 18 May 2026 17:46:33 +0100 Subject: [PATCH 4/8] Update config + tests --- helm/blueapi/config_schema.json | 39 +++++++++++++++++++++++++++++++++ helm/blueapi/values.schema.json | 38 ++++++++++++++++++++++++++++++++ src/blueapi/config.py | 2 +- tests/unit_tests/test_config.py | 6 +++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index b5d0c9bf3..0b7ad2ef8 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -330,6 +330,34 @@ "type": "object", "$id": "OIDCConfig" }, + "OpaConfig": { + "additionalProperties": false, + "properties": { + "root": { + "default": "http://localhost:8181/", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Root", + "type": "string" + }, + "tiled_service_account_check": { + "title": "Tiled Service Account Check", + "type": "string" + }, + "submit_plan_check": { + "title": "Submit Plan Check", + "type": "string" + } + }, + "required": [ + "tiled_service_account_check", + "submit_plan_check" + ], + "title": "OpaConfig", + "type": "object", + "$id": "OpaConfig" + }, "PlanSource": { "additionalProperties": false, "properties": { @@ -612,6 +640,17 @@ } ], "default": null + }, + "opa": { + "anyOf": [ + { + "$ref": "OpaConfig" + }, + { + "type": "null" + } + ], + "default": null } }, "title": "ApplicationConfig", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 74deedadb..cddbc693b 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -751,6 +751,34 @@ }, "additionalProperties": false }, + "OpaConfig": { + "$id": "OpaConfig", + "title": "OpaConfig", + "type": "object", + "required": [ + "tiled_service_account_check", + "submit_plan_check" + ], + "properties": { + "root": { + "title": "Root", + "default": "http://localhost:8181/", + "type": "string", + "format": "uri", + "maxLength": 2083, + "minLength": 1 + }, + "submit_plan_check": { + "title": "Submit Plan Check", + "type": "string" + }, + "tiled_service_account_check": { + "title": "Tiled Service Account Check", + "type": "string" + } + }, + "additionalProperties": false + }, "PlanSource": { "$id": "PlanSource", "title": "PlanSource", @@ -1011,6 +1039,16 @@ } ] }, + "opa": { + "anyOf": [ + { + "$ref": "OpaConfig" + }, + { + "type": "null" + } + ] + }, "scratch": { "anyOf": [ { diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 7ac61e711..b6124401e 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -297,7 +297,7 @@ class Tag(StrEnum): class OpaConfig(BlueapiBaseModel): - root: HttpUrl + root: HttpUrl = HttpUrl("http://localhost:8181") tiled_service_account_check: str submit_plan_check: str diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 30fe551c4..897c8e752 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -337,6 +337,11 @@ def test_config_yaml_parsed(temp_yaml_config_file): } ], }, + "opa": { + "root": "http://opa.example.com/", + "submit_plan_check": "v1/submit_plan", + "tiled_service_account_check": "v1/tiled_service_account", + }, }, { "stomp": { @@ -392,6 +397,7 @@ def test_config_yaml_parsed(temp_yaml_config_file): } ], }, + "opa": None, }, ], indirect=True, From d513ffa35ef160a9f3899d483c678ce75161797e Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Mon, 18 May 2026 17:48:40 +0100 Subject: [PATCH 5/8] Check tiled config at startup --- src/blueapi/service/authorization.py | 7 +++++-- src/blueapi/service/main.py | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py index 9ea72c402..917ff7aaf 100644 --- a/src/blueapi/service/authorization.py +++ b/src/blueapi/service/authorization.py @@ -41,8 +41,11 @@ async def check(self, endpoint: str, data: Mapping[str, Any]): LOGGER.exception("Failed to run check", e) raise - async def require_tiled_service_account(self, token: str): - await self.check(self._conf.tiled_service_account_check, {"token": token}) + async def require_tiled_service_account(self, token: str, instrument: str): + await self.check( + self._conf.tiled_service_account_check, + {"token": token, "beamline": instrument}, + ) async def submit_plan_check(self, token: str, instrument_session: str): if not (match := INSTRUMENT_SESSION_RE.match(instrument_session)): diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 25b0ab47a..9080a95a1 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -36,8 +36,9 @@ from super_state_machine.errors import TransitionError from blueapi import __version__ -from blueapi.config import ApplicationConfig, OIDCConfig, Tag +from blueapi.config import ApplicationConfig, OIDCConfig, ServiceAccount, Tag from blueapi.service import interface +from blueapi.service.authentication import TiledAuth from blueapi.worker import TrackableTask, WorkerState from blueapi.worker.event import TaskStatusEnum @@ -94,15 +95,32 @@ def teardown_runner(): def lifespan(config: ApplicationConfig): @asynccontextmanager async def inner(app: FastAPI): + if not config.env.metadata: + raise ValueError("Instrument name is required in metadata") setup_runner(config) if config.env.metadata and config.opa: app.state.authz = OpaClient(config.env.metadata.instrument, config.opa) + if isinstance(config.tiled.authentication, ServiceAccount) and config.oidc: + await validate_tiled_config( + config.tiled.authentication, + config.oidc, + app.state.authz, + config.env.metadata.instrument, + ) yield teardown_runner() return inner +async def validate_tiled_config( + tiled: ServiceAccount, oidc: OIDCConfig, opa: OpaClient, instrument: str +): + tiled.token_url = oidc.token_endpoint + auth = TiledAuth(tiled) + await opa.require_tiled_service_account(auth.get_access_token(), instrument) + + open_router = APIRouter() secure_router = APIRouter(deprecated=True) secure_router_v1 = APIRouter(prefix="/api/v1") From 61094275baa5ad148d6ee0841f2ca5370e11ce68 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Tue, 19 May 2026 10:37:11 +0100 Subject: [PATCH 6/8] Move HTTPException to auth_check --- src/blueapi/service/main.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 9080a95a1..5e99ae9a0 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -166,18 +166,23 @@ def bearer_token(req: Request) -> str | None: auth = req.headers.get("Authorization") scheme, param = get_authorization_scheme_param(auth) if scheme.casefold() != "bearer": - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) + return None return param.strip() def decode_access_token(config: OIDCConfig): jwkclient = jwt.PyJWKClient(config.jwks_uri) - def inner(request: Request, access_token: Annotated[str, Depends(bearer_token)]): + def inner( + request: Request, access_token: Annotated[str | None, Depends(bearer_token)] + ): + if not access_token: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + signing_key = jwkclient.get_signing_key_from_jwt(access_token) decoded: dict[str, Any] = jwt.decode( access_token, From b25a9aadeba3a5ba0e15b39d741ec835a25c50b4 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Tue, 19 May 2026 11:11:40 +0100 Subject: [PATCH 7/8] Refactor opa checks --- src/blueapi/service/authorization.py | 26 ++++++++++++++------------ src/blueapi/service/main.py | 10 ++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py index 917ff7aaf..886b163f1 100644 --- a/src/blueapi/service/authorization.py +++ b/src/blueapi/service/authorization.py @@ -23,35 +23,36 @@ def __init__(self, instrument: str, config: OpaConfig): LOGGER.info("Creating OpaClient for %s with config %s", instrument, config) self._instrument = instrument self._conf = config - self._url = config.root.encoded_string() self._session = ClientSession(base_url=config.root.encoded_string()) def for_token(self, token: str) -> "OpaUserClient": return OpaUserClient(self, token) - async def check(self, endpoint: str, data: Mapping[str, Any]): + async def _call_opa(self, endpoint, data: Mapping[str, Any]) -> bool: try: resp = await self._session.post( endpoint, json={"input": {"beamline": self._instrument, **data}}, ) - if not (await resp.json())["result"]: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) - except Exception as e: - LOGGER.exception("Failed to run check", e) + return (await resp.json())["result"] + except Exception: + LOGGER.exception("Failed to run check", exc_info=True) raise - async def require_tiled_service_account(self, token: str, instrument: str): - await self.check( + async def require_tiled_service_account(self, token: str): + if not await self._call_opa( self._conf.tiled_service_account_check, - {"token": token, "beamline": instrument}, - ) + {"token": token, "beamline": self._instrument}, + ): + raise ValueError( + f"Tiled service account is not valid for '{self._instrument}'" + ) async def submit_plan_check(self, token: str, instrument_session: str): if not (match := INSTRUMENT_SESSION_RE.match(instrument_session)): raise ValueError("Invalid instrument session") - await self.check( + if not await self._call_opa( self._conf.submit_plan_check, { "token": token, @@ -59,7 +60,8 @@ async def submit_plan_check(self, token: str, instrument_session: str): "proposal": int(match["proposal"]), "visit": int(match["visit"]), }, - ) + ): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) class OpaUserClient: diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 5e99ae9a0..58a85518d 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -102,10 +102,7 @@ async def inner(app: FastAPI): app.state.authz = OpaClient(config.env.metadata.instrument, config.opa) if isinstance(config.tiled.authentication, ServiceAccount) and config.oidc: await validate_tiled_config( - config.tiled.authentication, - config.oidc, - app.state.authz, - config.env.metadata.instrument, + config.tiled.authentication, config.oidc, app.state.authz ) yield teardown_runner() @@ -114,11 +111,12 @@ async def inner(app: FastAPI): async def validate_tiled_config( - tiled: ServiceAccount, oidc: OIDCConfig, opa: OpaClient, instrument: str + tiled: ServiceAccount, oidc: OIDCConfig, opa: OpaClient ): + LOGGER.info("Validating tiled configuration") tiled.token_url = oidc.token_endpoint auth = TiledAuth(tiled) - await opa.require_tiled_service_account(auth.get_access_token(), instrument) + await opa.require_tiled_service_account(auth.get_access_token()) open_router = APIRouter() From ab6a11f9ab52812f6eb21297fba85cf3b232acc5 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Tue, 19 May 2026 11:18:26 +0100 Subject: [PATCH 8/8] Close opa client session --- src/blueapi/service/authorization.py | 3 +++ src/blueapi/service/main.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py index 886b163f1..abe0b2679 100644 --- a/src/blueapi/service/authorization.py +++ b/src/blueapi/service/authorization.py @@ -28,6 +28,9 @@ def __init__(self, instrument: str, config: OpaConfig): def for_token(self, token: str) -> "OpaUserClient": return OpaUserClient(self, token) + async def close(self): + await self._session.close() + async def _call_opa(self, endpoint, data: Mapping[str, Any]) -> bool: try: resp = await self._session.post( diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 58a85518d..f7682e76b 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -98,13 +98,17 @@ async def inner(app: FastAPI): if not config.env.metadata: raise ValueError("Instrument name is required in metadata") setup_runner(config) - if config.env.metadata and config.opa: - app.state.authz = OpaClient(config.env.metadata.instrument, config.opa) - if isinstance(config.tiled.authentication, ServiceAccount) and config.oidc: - await validate_tiled_config( - config.tiled.authentication, config.oidc, app.state.authz - ) - yield + try: + if config.env.metadata and config.opa: + app.state.authz = OpaClient(config.env.metadata.instrument, config.opa) + if isinstance(config.tiled.authentication, ServiceAccount) and config.oidc: + await validate_tiled_config( + config.tiled.authentication, config.oidc, app.state.authz + ) + yield + finally: + if app.state.authz: + await app.state.authz.close() teardown_runner() return inner