Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,27 @@
"type": "object",
"$id": "OIDCConfig"
},
"OpaConfig": {
"additionalProperties": false,
"properties": {
"root": {
"default": "http://localhost:8181/",
"format": "uri",
"maxLength": 2083,
"minLength": 1,
"title": "Root",
"type": "string"
},
"audience": {
"default": "account",
"title": "Audience",
"type": "string"
}
},
"title": "OpaConfig",
"type": "object",
"$id": "OpaConfig"
},
"PlanSource": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -612,6 +633,17 @@
}
],
"default": null
},
"opa": {
"anyOf": [
{
"$ref": "OpaConfig"
},
{
"type": "null"
}
],
"default": null
}
},
"title": "ApplicationConfig",
Expand Down
31 changes: 31 additions & 0 deletions helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,27 @@
},
"additionalProperties": false
},
"OpaConfig": {
"$id": "OpaConfig",
"title": "OpaConfig",
"type": "object",
"properties": {
"audience": {
"title": "Audience",
"default": "account",
"type": "string"
},
"root": {
"title": "Root",
"default": "http://localhost:8181/",
"type": "string",
"format": "uri",
"maxLength": 2083,
"minLength": 1
}
},
"additionalProperties": false
},
Comment thread
tpoliaw marked this conversation as resolved.
Resolved
"PlanSource": {
"$id": "PlanSource",
"title": "PlanSource",
Expand Down Expand Up @@ -1011,6 +1032,16 @@
}
]
},
"opa": {
"anyOf": [
{
"$ref": "OpaConfig"
},
{
"type": "null"
}
]
},
"scratch": {
"anyOf": [
{
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies = [
"tomlkit",
"graypy>=2.1.0",
"httpx>=0.28.1",
"aiohttp>=3.13.5",
]
dynamic = ["version"]
license.file = "LICENSE"
Expand Down
7 changes: 7 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ class Tag(StrEnum):
META = "Meta"


class OpaConfig(BlueapiBaseModel):
root: HttpUrl = HttpUrl("http://localhost:8181")
Comment thread
tpoliaw marked this conversation as resolved.
Resolved
audience: str = "account"


class ApplicationConfig(BlueapiBaseModel):
"""
Config for the worker application as a whole. Root of
Expand Down Expand Up @@ -335,6 +340,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):
Expand All @@ -343,6 +349,7 @@ def __eq__(self, other: object) -> bool:
& (self.env == other.env)
& (self.logging == other.logging)
& (self.api == other.api)
& (self.opa == other.opa)
Comment thread
tpoliaw marked this conversation as resolved.
Resolved
)
return False

Expand Down
9 changes: 9 additions & 0 deletions src/blueapi/service/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,12 @@ def validate_bearer_token(request: Request, token: UncheckedBearerToken):
def access_token(request: Request) -> Mapping[str, Any] | None:
"""Get the decoded and verified access token of the user making the request"""
return getattr(request.state, "decoded_access_token", None)


def fedid(
access_token: Annotated[Mapping[str, Any] | None, Depends(access_token)],
) -> str | None:
return access_token.get("fedid") if access_token else None


Fedid = Annotated[str | None, Depends(fedid)]
47 changes: 47 additions & 0 deletions src/blueapi/service/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import logging
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager, aclosing, nullcontext
from typing import Any, Self

from aiohttp import ClientSession

from blueapi.config import OpaConfig

LOGGER = logging.getLogger(__name__)


class OpaClient:
def __init__(self, instrument: str, config: OpaConfig):
LOGGER.info("Creating OpaClient for %s with config %s", instrument, config)
self._instrument = instrument
self._config = config
self._session = ClientSession(base_url=config.root.encoded_string())
self._audience = config.audience

async def aclose(self):
LOGGER.info("Closing OPA session")
await self._session.close()

async def _call_opa(self, endpoint: str, data: Mapping[str, Any]) -> bool:
resp = await self._session.post(
endpoint,
json={
"input": {
"beamline": self._instrument,
"audience": self._audience,
**data,
}
},
)
return (await resp.json())["result"]
Comment thread
ZohebShaikh marked this conversation as resolved.
Resolved

@classmethod
def for_config(
cls, instrument: str | None, config: OpaConfig | None
) -> AbstractAsyncContextManager[Self | None]:
if config:
if not instrument:
raise ValueError("Instrument name is required for OPA client")
return aclosing(cls(instrument, config))
LOGGER.info("No OPA config provided - not creating OpaClient")
return nullcontext()
21 changes: 9 additions & 12 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import urllib.parse
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Annotated, Any
from typing import Annotated

import jwt
from fastapi import (
Expand Down Expand Up @@ -36,10 +36,11 @@
from blueapi import __version__
from blueapi.config import ApplicationConfig, OIDCConfig, Tag
from blueapi.service import interface
from blueapi.service.authentication import build_access_token_check
from blueapi.service.authentication import Fedid, build_access_token_check
from blueapi.worker import TrackableTask, WorkerState
from blueapi.worker.event import TaskStatusEnum

from .authorization import OpaClient
from .model import (
DeviceModel,
DeviceResponse,
Expand Down Expand Up @@ -93,8 +94,11 @@ def teardown_runner():
def lifespan(config: ApplicationConfig):
@asynccontextmanager
async def inner(app: FastAPI):
meta = config.env.metadata
setup_runner(config)
yield
async with OpaClient.for_config(meta and meta.instrument, config.opa) as opa:
app.state.authz = opa
yield
teardown_runner()

return inner
Expand Down Expand Up @@ -267,18 +271,11 @@ def submit_task(
response: Response,
task_request: Annotated[TaskRequest, Body(..., examples=[example_task_request])],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
user: Fedid,
) -> TaskResponse:
"""Submit a task to the worker."""
try:
# Extract user from jwt if using OIDC (if jwt exists)
access_token: dict[str, Any] | None = getattr(
request.state, "decoded_access_token", None
)
if access_token:
user: str = access_token.get("fedid", "Unknown")
else:
user = "Unknown"

user = user or "Unknown"
task_id: str = runner.run(interface.submit_task, task_request, {"user": user})
response.headers["Location"] = f"{request.url}/{task_id}"
return TaskResponse(task_id=task_id)
Expand Down
62 changes: 62 additions & 0 deletions tests/unit_tests/service/test_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic import HttpUrl

from blueapi.config import OpaConfig
from blueapi.service.authorization import (
OpaClient,
)

# Reusable client patch decorator
patch_client_session = patch(
"blueapi.service.authorization.ClientSession",
name="mock_client_session",
spec=True,
)


@pytest.fixture(scope="module")
def opa_config() -> OpaConfig:
return OpaConfig(
root=HttpUrl("http://auth.example.com"),
)


@patch_client_session
async def test_session_closed(session: MagicMock, opa_config: OpaConfig):
async with OpaClient.for_config("p45", opa_config):
pass
session().close.assert_called_once()


@patch_client_session
async def test_opa_client_for_config(session: MagicMock, opa_config: OpaConfig):
async with OpaClient.for_config("p45", opa_config) as opa:
assert opa is not None
session.assert_called_once_with(base_url="http://auth.example.com/")


@pytest.mark.parametrize("instrument", [None, "p99"])
async def test_opa_client_without_config(instrument: str | None):
async with OpaClient.for_config(instrument, None) as opa:
assert opa is 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)


@patch_client_session
async def test_opa_adds_input_fields(session: MagicMock, opa_config: OpaConfig):
session.return_value.post = AsyncMock()
async with OpaClient.for_config("p45", opa_config) as opa:
assert opa is not None
await opa._call_opa("foo/bar", data={"foo": "bar"})

session.assert_called_once()
session().post.assert_called_once_with(
"foo/bar",
json={"input": {"beamline": "p45", "audience": "account", "foo": "bar"}},
)
18 changes: 17 additions & 1 deletion tests/unit_tests/service/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from unittest import mock
from unittest.mock import Mock, call
from unittest.mock import Mock, call, patch

import pytest
from fastapi import FastAPI, Request
Expand All @@ -10,6 +10,7 @@
from blueapi.service.main import (
add_version_headers,
get_passthrough_headers,
lifespan,
log_request_details,
)

Expand Down Expand Up @@ -79,3 +80,18 @@ def test_get_passthrough_headers(
request = Mock(spec=Request)
request.headers = headers
assert get_passthrough_headers(request) == expected_headers


@patch("blueapi.service.main.teardown_runner")
@patch("blueapi.service.main.setup_runner")
async def test_lifespan(setup: Mock, teardown: Mock):
conf = ApplicationConfig()
lifespan_fn = lifespan(conf)

app = Mock()

async with lifespan_fn(app):
setup.assert_called_once_with(conf)
teardown.assert_not_called()

teardown.assert_called_once()
5 changes: 5 additions & 0 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ def test_config_yaml_parsed(temp_yaml_config_file):
}
],
},
"opa": {
"root": "http://opa.example.com/",
"audience": "account",
},
},
{
"stomp": {
Expand Down Expand Up @@ -392,6 +396,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
}
],
},
"opa": None,
},
],
indirect=True,
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading