Skip to content

Commit 7fe3fda

Browse files
authored
feat: Add OpaClient to wrap auth checks (#1541)
Proof of concept opa client with dependency injection and example check
1 parent cc514f5 commit 7fe3fda

10 files changed

Lines changed: 209 additions & 2 deletions

File tree

helm/blueapi/config_schema.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,27 @@
330330
"type": "object",
331331
"$id": "OIDCConfig"
332332
},
333+
"OpaConfig": {
334+
"additionalProperties": false,
335+
"properties": {
336+
"root": {
337+
"default": "http://localhost:8181/",
338+
"format": "uri",
339+
"maxLength": 2083,
340+
"minLength": 1,
341+
"title": "Root",
342+
"type": "string"
343+
},
344+
"audience": {
345+
"default": "account",
346+
"title": "Audience",
347+
"type": "string"
348+
}
349+
},
350+
"title": "OpaConfig",
351+
"type": "object",
352+
"$id": "OpaConfig"
353+
},
333354
"PlanSource": {
334355
"additionalProperties": false,
335356
"properties": {
@@ -612,6 +633,17 @@
612633
}
613634
],
614635
"default": null
636+
},
637+
"opa": {
638+
"anyOf": [
639+
{
640+
"$ref": "OpaConfig"
641+
},
642+
{
643+
"type": "null"
644+
}
645+
],
646+
"default": null
615647
}
616648
},
617649
"title": "ApplicationConfig",

helm/blueapi/values.schema.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,27 @@
751751
},
752752
"additionalProperties": false
753753
},
754+
"OpaConfig": {
755+
"$id": "OpaConfig",
756+
"title": "OpaConfig",
757+
"type": "object",
758+
"properties": {
759+
"audience": {
760+
"title": "Audience",
761+
"default": "account",
762+
"type": "string"
763+
},
764+
"root": {
765+
"title": "Root",
766+
"default": "http://localhost:8181/",
767+
"type": "string",
768+
"format": "uri",
769+
"maxLength": 2083,
770+
"minLength": 1
771+
}
772+
},
773+
"additionalProperties": false
774+
},
754775
"PlanSource": {
755776
"$id": "PlanSource",
756777
"title": "PlanSource",
@@ -1011,6 +1032,16 @@
10111032
}
10121033
]
10131034
},
1035+
"opa": {
1036+
"anyOf": [
1037+
{
1038+
"$ref": "OpaConfig"
1039+
},
1040+
{
1041+
"type": "null"
1042+
}
1043+
]
1044+
},
10141045
"scratch": {
10151046
"anyOf": [
10161047
{

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"tomlkit",
3838
"graypy>=2.1.0",
3939
"httpx>=0.28.1",
40+
"aiohttp>=3.13.5",
4041
]
4142
dynamic = ["version"]
4243
license.file = "LICENSE"

src/blueapi/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ class Tag(StrEnum):
296296
META = "Meta"
297297

298298

299+
class OpaConfig(BlueapiBaseModel):
300+
root: HttpUrl = HttpUrl("http://localhost:8181")
301+
audience: str = "account"
302+
303+
299304
class ApplicationConfig(BlueapiBaseModel):
300305
"""
301306
Config for the worker application as a whole. Root of
@@ -335,6 +340,7 @@ class ApplicationConfig(BlueapiBaseModel):
335340
oidc: OIDCConfig | None = None
336341
auth_token_path: Path | None = None
337342
numtracker: NumtrackerConfig | None = None
343+
opa: OpaConfig | None = None
338344

339345
def __eq__(self, other: object) -> bool:
340346
if isinstance(other, ApplicationConfig):
@@ -343,6 +349,7 @@ def __eq__(self, other: object) -> bool:
343349
& (self.env == other.env)
344350
& (self.logging == other.logging)
345351
& (self.api == other.api)
352+
& (self.opa == other.opa)
346353
)
347354
return False
348355

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import logging
2+
from collections.abc import Mapping
3+
from contextlib import AbstractAsyncContextManager, aclosing, nullcontext
4+
from typing import Any, Self
5+
6+
from aiohttp import ClientSession
7+
8+
from blueapi.config import OpaConfig
9+
10+
LOGGER = logging.getLogger(__name__)
11+
12+
13+
class OpaClient:
14+
def __init__(self, instrument: str, config: OpaConfig):
15+
LOGGER.info("Creating OpaClient for %s with config %s", instrument, config)
16+
self._instrument = instrument
17+
self._config = config
18+
self._session = ClientSession(base_url=config.root.encoded_string())
19+
self._audience = config.audience
20+
21+
async def aclose(self):
22+
LOGGER.info("Closing OPA session")
23+
await self._session.close()
24+
25+
async def _call_opa(self, endpoint: str, data: Mapping[str, Any]) -> bool:
26+
resp = await self._session.post(
27+
endpoint,
28+
json={
29+
"input": {
30+
"beamline": self._instrument,
31+
"audience": self._audience,
32+
**data,
33+
}
34+
},
35+
)
36+
return (await resp.json())["result"]
37+
38+
@classmethod
39+
def for_config(
40+
cls, instrument: str | None, config: OpaConfig | None
41+
) -> AbstractAsyncContextManager[Self | None]:
42+
if config:
43+
if not instrument:
44+
raise ValueError("Instrument name is required for OPA client")
45+
return aclosing(cls(instrument, config))
46+
LOGGER.info("No OPA config provided - not creating OpaClient")
47+
return nullcontext()

src/blueapi/service/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from blueapi.worker import TrackableTask, WorkerState
4141
from blueapi.worker.event import TaskStatusEnum
4242

43+
from .authorization import OpaClient
4344
from .model import (
4445
DeviceModel,
4546
DeviceResponse,
@@ -93,8 +94,11 @@ def teardown_runner():
9394
def lifespan(config: ApplicationConfig):
9495
@asynccontextmanager
9596
async def inner(app: FastAPI):
97+
meta = config.env.metadata
9698
setup_runner(config)
97-
yield
99+
async with OpaClient.for_config(meta and meta.instrument, config.opa) as opa:
100+
app.state.authz = opa
101+
yield
98102
teardown_runner()
99103

100104
return inner
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from unittest.mock import AsyncMock, MagicMock, patch
2+
3+
import pytest
4+
from pydantic import HttpUrl
5+
6+
from blueapi.config import OpaConfig
7+
from blueapi.service.authorization import (
8+
OpaClient,
9+
)
10+
11+
# Reusable client patch decorator
12+
patch_client_session = patch(
13+
"blueapi.service.authorization.ClientSession",
14+
name="mock_client_session",
15+
spec=True,
16+
)
17+
18+
19+
@pytest.fixture(scope="module")
20+
def opa_config() -> OpaConfig:
21+
return OpaConfig(
22+
root=HttpUrl("http://auth.example.com"),
23+
)
24+
25+
26+
@patch_client_session
27+
async def test_session_closed(session: MagicMock, opa_config: OpaConfig):
28+
async with OpaClient.for_config("p45", opa_config):
29+
pass
30+
session().close.assert_called_once()
31+
32+
33+
@patch_client_session
34+
async def test_opa_client_for_config(session: MagicMock, opa_config: OpaConfig):
35+
async with OpaClient.for_config("p45", opa_config) as opa:
36+
assert opa is not None
37+
session.assert_called_once_with(base_url="http://auth.example.com/")
38+
39+
40+
@pytest.mark.parametrize("instrument", [None, "p99"])
41+
async def test_opa_client_without_config(instrument: str | None):
42+
async with OpaClient.for_config(instrument, None) as opa:
43+
assert opa is None
44+
45+
46+
async def test_opa_fails_without_instrument(opa_config: OpaConfig):
47+
with pytest.raises(ValueError, match="Instrument name is required"):
48+
OpaClient.for_config(None, opa_config)
49+
50+
51+
@patch_client_session
52+
async def test_opa_adds_input_fields(session: MagicMock, opa_config: OpaConfig):
53+
session.return_value.post = AsyncMock()
54+
async with OpaClient.for_config("p45", opa_config) as opa:
55+
assert opa is not None
56+
await opa._call_opa("foo/bar", data={"foo": "bar"})
57+
58+
session.assert_called_once()
59+
session().post.assert_called_once_with(
60+
"foo/bar",
61+
json={"input": {"beamline": "p45", "audience": "account", "foo": "bar"}},
62+
)

tests/unit_tests/service/test_main.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from unittest import mock
2-
from unittest.mock import Mock, call
2+
from unittest.mock import Mock, call, patch
33

44
import pytest
55
from fastapi import FastAPI, Request
@@ -10,6 +10,7 @@
1010
from blueapi.service.main import (
1111
add_version_headers,
1212
get_passthrough_headers,
13+
lifespan,
1314
log_request_details,
1415
)
1516

@@ -79,3 +80,18 @@ def test_get_passthrough_headers(
7980
request = Mock(spec=Request)
8081
request.headers = headers
8182
assert get_passthrough_headers(request) == expected_headers
83+
84+
85+
@patch("blueapi.service.main.teardown_runner")
86+
@patch("blueapi.service.main.setup_runner")
87+
async def test_lifespan(setup: Mock, teardown: Mock):
88+
conf = ApplicationConfig()
89+
lifespan_fn = lifespan(conf)
90+
91+
app = Mock()
92+
93+
async with lifespan_fn(app):
94+
setup.assert_called_once_with(conf)
95+
teardown.assert_not_called()
96+
97+
teardown.assert_called_once()

tests/unit_tests/test_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,10 @@ def test_config_yaml_parsed(temp_yaml_config_file):
337337
}
338338
],
339339
},
340+
"opa": {
341+
"root": "http://opa.example.com/",
342+
"audience": "account",
343+
},
340344
},
341345
{
342346
"stomp": {
@@ -392,6 +396,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
392396
}
393397
],
394398
},
399+
"opa": None,
395400
},
396401
],
397402
indirect=True,

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)