From a3a1f15eea272fecf24f6c2a1221833ad81995ae Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 21 May 2026 16:45:44 +0100 Subject: [PATCH 1/2] Validate tiled service account configuration at startup --- helm/blueapi/config_schema.json | 7 +++++++ helm/blueapi/values.schema.json | 7 +++++++ src/blueapi/config.py | 1 + src/blueapi/service/authorization.py | 28 +++++++++++++++++++++++++++- src/blueapi/service/main.py | 3 ++- tests/unit_tests/test_config.py | 1 + 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 1ad4e82bf..68fc79539 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -340,8 +340,15 @@ "minLength": 1, "title": "Root", "type": "string" + }, + "tiled_service_account_check": { + "title": "Tiled Service Account Check", + "type": "string" } }, + "required": [ + "tiled_service_account_check" + ], "title": "OpaConfig", "type": "object", "$id": "OpaConfig" diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 6de532cc8..7472c37a2 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -755,6 +755,9 @@ "$id": "OpaConfig", "title": "OpaConfig", "type": "object", + "required": [ + "tiled_service_account_check" + ], "properties": { "root": { "title": "Root", @@ -763,6 +766,10 @@ "format": "uri", "maxLength": 2083, "minLength": 1 + }, + "tiled_service_account_check": { + "title": "Tiled Service Account Check", + "type": "string" } }, "additionalProperties": false diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 4c2431cf1..06c149955 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -298,6 +298,7 @@ class Tag(StrEnum): class OpaConfig(BlueapiBaseModel): root: HttpUrl = HttpUrl("http://localhost:8181") + tiled_service_account_check: str class ApplicationConfig(BlueapiBaseModel): diff --git a/src/blueapi/service/authorization.py b/src/blueapi/service/authorization.py index b27c304c9..3be0814d4 100644 --- a/src/blueapi/service/authorization.py +++ b/src/blueapi/service/authorization.py @@ -6,7 +6,8 @@ import aiohttp from aiohttp import ClientSession -from blueapi.config import OpaConfig +from blueapi.config import OIDCConfig, OpaConfig, ServiceAccount +from blueapi.service.authentication import TiledAuth LOGGER = logging.getLogger(__name__) @@ -52,6 +53,14 @@ def for_config( LOGGER.info("No OPA config provided - not creating OpaClient") return nullcontext() + async def require_tiled_service_account(self, token: str): + if not await self._call_opa( + self._conf.tiled_service_account_check, + {"token": token, "beamline": self._instrument}, + ): + raise ValueError( + f"Tiled service account is not valid for '{self._instrument}'" + ) class OpaUserClient: @@ -61,3 +70,20 @@ class OpaUserClient: def __init__(self, client: OpaClient, token: str): self.client = client self.token = token + + +async def validate_tiled_config( + tiled: ServiceAccount | str | None, oidc: OIDCConfig | None, opa: OpaClient | None +): + if not isinstance(tiled, ServiceAccount): + # can't validate an API key + return + + if not opa or not oidc: + LOGGER.info("Missing OPA or OIDC configuration required to validate tiled auth") + return + + LOGGER.info("Validating tiled configuration") + tiled.token_url = oidc.token_endpoint + auth = TiledAuth(tiled) + await opa.require_tiled_service_account(auth.get_access_token()) diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 462c9318f..3114fa73f 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -40,7 +40,7 @@ from blueapi.worker import TrackableTask, WorkerState from blueapi.worker.event import TaskStatusEnum -from .authorization import OpaClient +from .authorization import OpaClient, validate_tiled_config from .model import ( DeviceModel, DeviceResponse, @@ -98,6 +98,7 @@ async def inner(app: FastAPI): setup_runner(config) async with OpaClient.for_config(meta and meta.instrument, config.opa) as opa: app.state.authz = opa + await validate_tiled_config(config.tiled.authentication, config.oidc, opa) yield teardown_runner() diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 7ce98f6fe..b6e52c393 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -339,6 +339,7 @@ def test_config_yaml_parsed(temp_yaml_config_file): }, "opa": { "root": "http://opa.example.com/", + "tiled_service_account_check": "v1/tiled_service_account", }, }, { From 403846c6194c07087277a18a00ae3debf52a3c71 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 28 May 2026 17:22:29 +0100 Subject: [PATCH 2/2] Add tests for tiled check --- .../unit_tests/service/test_authorization.py | 93 ++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/service/test_authorization.py b/tests/unit_tests/service/test_authorization.py index aa4417d50..ca3500bba 100644 --- a/tests/unit_tests/service/test_authorization.py +++ b/tests/unit_tests/service/test_authorization.py @@ -1,11 +1,13 @@ -from unittest.mock import MagicMock, patch +from contextlib import AbstractContextManager, nullcontext +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from pydantic import HttpUrl -from blueapi.config import OpaConfig +from blueapi.config import OIDCConfig, OpaConfig, ServiceAccount from blueapi.service.authorization import ( OpaClient, + validate_tiled_config, ) # Reusable client patch decorator @@ -20,9 +22,50 @@ def opa_config() -> OpaConfig: return OpaConfig( root=HttpUrl("http://auth.example.com"), + tiled_service_account_check="/auth/tiled", ) +@patch_client_session +@pytest.mark.parametrize( + "result,context", + [ + (False, pytest.raises(ValueError, match="Tiled service account is not valid ")), + (True, nullcontext()), + ], +) +async def test_tiled_service_account( + session: MagicMock, + opa_config: OpaConfig, + result: bool, + context: AbstractContextManager, +): + session.return_value.post = AsyncMock( + return_value=MagicMock(json=AsyncMock(return_value={"result": result})) + ) + + client = OpaClient(instrument="p99", config=opa_config) + + session.assert_called_once_with(base_url="http://auth.example.com/") + with context: + await client.require_tiled_service_account(token="foo_bar") + session().post.assert_called_once_with( + "/auth/tiled", + json={"input": {"token": "foo_bar", "beamline": "p99", "audience": "account"}}, + ) + + +@patch_client_session +async def test_exception_raised_when_opa_fails( + session: MagicMock, opa_config: OpaConfig +): + session.return_value.post = AsyncMock(side_effect=RuntimeError("Connection failed")) + async with OpaClient.for_config("p45", opa_config) as client: + assert client is not None + with pytest.raises(RuntimeError, match="Connection failed"): + await client.require_tiled_service_account(token="foo_bar") + + @patch_client_session async def test_session_closed(session: MagicMock, opa_config: OpaConfig): async with OpaClient.for_config("p45", opa_config): @@ -46,3 +89,49 @@ async def test_opa_client_without_config(instrument: str | None): async def test_opa_fails_without_instrument(opa_config: OpaConfig): with pytest.raises(ValueError, match="Instrument name is required"): OpaClient.for_config(None, opa_config) + + +async def test_validate_tiled_config(): + opa = MagicMock(spec=OpaClient) + tiled = ServiceAccount() + oidc = Mock(spec=OIDCConfig) + oidc.token_endpoint = "token-endpoint" + with patch("blueapi.service.authorization.TiledAuth") as auth: + auth.return_value.get_access_token.return_value = "tiled-token" + await validate_tiled_config(tiled, oidc, opa) + + auth.assert_called_once_with(tiled) + opa.require_tiled_service_account.assert_called_once_with("tiled-token") + + +@pytest.mark.parametrize( + "tiled_auth,oidc,opa_client", + [ + (None, None, MagicMock(spec=OpaClient)), + ( + None, + OIDCConfig(well_known_url="http://example.com", client_id="test-client"), + MagicMock(spec=OpaClient), + ), + ("api_key", None, MagicMock(spec=OpaClient)), + ( + "api_key", + OIDCConfig(well_known_url="http://example.com", client_id="test-client"), + MagicMock(spec=OpaClient), + ), + (ServiceAccount(), None, MagicMock(spec=OpaClient)), + ( + ServiceAccount(), + OIDCConfig(well_known_url="http://example.com", client_id="test-client"), + None, + ), + ], +) +async def test_validate_tiled_config_with_missing_config( + tiled_auth: ServiceAccount | str | None, + oidc: OIDCConfig | None, + opa_client: MagicMock | None, +): + assert await validate_tiled_config(tiled_auth, oidc, opa_client) is None + if opa_client is not None: + opa_client.require_tiled_service_account.assert_not_called()