Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3b5b8a2
refactor: Move auth extractors into authentication module
tpoliaw May 21, 2026
e6ca161
Use Depends injection to extract user name
tpoliaw Apr 17, 2026
0e68df4
feat: Add OpaConfig for authorization configuration
tpoliaw May 21, 2026
6af44c0
Add OpaClient to wrap OPA interactions
tpoliaw May 21, 2026
d2cee54
Create OpaClient as part of server lifecycle
tpoliaw May 21, 2026
7ddf56d
Move instrument requirement into OpaClient init
tpoliaw May 27, 2026
0374da6
Add tests for OpaClient
tpoliaw May 28, 2026
8bc7d84
Validate tiled service account configuration at startup
tpoliaw May 21, 2026
41e28ca
Add tests for tiled check
tpoliaw May 28, 2026
4495151
Add opa dependency function to create OpaUserClient
tpoliaw May 26, 2026
f2f02de
test opa dependency function
tpoliaw May 28, 2026
82ff44b
Add can_submit_task auth check method and config
tpoliaw May 26, 2026
84efe6e
feat: add authz dependency injection
shree-iyengar-dls May 15, 2026
06a8bda
feat: add auth check dependency injections to task endpoints
shree-iyengar-dls May 18, 2026
7536275
feat: create new access task permission fns and add as dependencies
shree-iyengar-dls May 20, 2026
f066cbf
refactor: update rest api version
shree-iyengar-dls May 20, 2026
a56893d
comment out dependency addition in set_state
shree-iyengar-dls May 20, 2026
4518825
refactor: add admin check and check to set state function
May 20, 2026
060ec2e
Update dependency names
tpoliaw May 26, 2026
29e2a5a
Add missing admin check
tpoliaw May 26, 2026
3757ac5
Handle missing opa and fix tests
tpoliaw May 26, 2026
b4c61a7
Remove old admin method
tpoliaw May 26, 2026
38fc638
Use starlette statuses directly
tpoliaw May 28, 2026
b14a256
test task submission authz
tpoliaw May 28, 2026
072c36c
Use _config instead of _conf
tpoliaw Jun 5, 2026
f193a5b
Re-use instrument session regex
tpoliaw Jun 5, 2026
a372c17
remove task access check
tpoliaw Jun 5, 2026
cfc22d3
Add match to raises check
tpoliaw Jun 5, 2026
340ef65
Add exception detail
tpoliaw Jun 5, 2026
8ad021f
Let admin see all tasks
tpoliaw Jun 5, 2026
6aa6121
Start of api authz tests
tpoliaw Jun 5, 2026
824b4e8
Make get_tasks async to access authz check
tpoliaw Jun 8, 2026
e6a6917
Parametrise filter test to check with and without admin
tpoliaw Jun 8, 2026
0518099
Add test for deleting tasks
tpoliaw Jun 8, 2026
fa02ed5
Add test for submit without permission
tpoliaw Jun 8, 2026
a0161c3
Test setting other user's task active
tpoliaw Jun 9, 2026
74fffa9
Test getting other users task
tpoliaw Jun 9, 2026
50c2677
Add tests for set state
tpoliaw Jun 9, 2026
0912158
Remove print debugging
tpoliaw Jun 10, 2026
b65f9b5
Refactor mock_runner to make it clear what is being mocked
tpoliaw Jun 9, 2026
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
49 changes: 49 additions & 0 deletions helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,44 @@
"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"
},
"tiled_service_account_check": {
"title": "Tiled Service Account Check",
"type": "string"
},
"submit_task_check": {
"title": "Submit Task Check",
"type": "string"
},
"admin_check": {
"title": "Admin Check",
"type": "string"
}
},
"required": [
"tiled_service_account_check",
"submit_task_check",
"admin_check"
],
"title": "OpaConfig",
"type": "object",
"$id": "OpaConfig"
},
"PlanSource": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -612,6 +650,17 @@
}
],
"default": null
},
"opa": {
"anyOf": [
{
"$ref": "OpaConfig"
},
{
"type": "null"
}
],
"default": null
}
},
"title": "ApplicationConfig",
Expand Down
48 changes: 48 additions & 0 deletions helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,44 @@
},
"additionalProperties": false
},
"OpaConfig": {
"$id": "OpaConfig",
"title": "OpaConfig",
"type": "object",
"required": [
"tiled_service_account_check",
"submit_task_check",
"admin_check"
],
"properties": {
"admin_check": {
"title": "Admin Check",
"type": "string"
},
"audience": {
"title": "Audience",
"default": "account",
"type": "string"
},
"root": {
"title": "Root",
"default": "http://localhost:8181/",
"type": "string",
"format": "uri",
"maxLength": 2083,
"minLength": 1
},
"submit_task_check": {
"title": "Submit Task Check",
"type": "string"
},
"tiled_service_account_check": {
"title": "Tiled Service Account Check",
"type": "string"
}
},
"additionalProperties": false
},
"PlanSource": {
"$id": "PlanSource",
"title": "PlanSource",
Expand Down Expand Up @@ -1011,6 +1049,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
10 changes: 10 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ class Tag(StrEnum):
META = "Meta"


class OpaConfig(BlueapiBaseModel):
root: HttpUrl = HttpUrl("http://localhost:8181")
audience: str = "account"
tiled_service_account_check: str
submit_task_check: str
admin_check: str


class ApplicationConfig(BlueapiBaseModel):
"""
Config for the worker application as a whole. Root of
Expand Down Expand Up @@ -335,6 +343,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 +352,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

Expand Down
64 changes: 63 additions & 1 deletion src/blueapi/service/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@
import time
import webbrowser
from abc import ABC, abstractmethod
from collections.abc import Mapping
from functools import cached_property
from http import HTTPStatus
from pathlib import Path
from typing import Any, cast
from typing import Annotated, Any, cast

import httpx
import jwt
import requests
from fastapi import Depends, HTTPException, Request
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import TypeAdapter
from requests.auth import AuthBase
from starlette.status import HTTP_401_UNAUTHORIZED

from blueapi.config import OIDCConfig, ServiceAccount
from blueapi.service.model import Cache
Expand Down Expand Up @@ -272,3 +276,61 @@ def get_access_token(self):
def sync_auth_flow(self, request):
request.headers["Authorization"] = f"Bearer {self.get_access_token()}"
yield request


def unchecked_bearer_token(req: Request) -> str | None:
"""Get bearer token value from authorization header"""
auth = req.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(auth)
if scheme.casefold() != "bearer":
return None
return param.strip()


UncheckedBearerToken = Annotated[str | None, Depends(unchecked_bearer_token)]


def build_access_token_check(config: OIDCConfig):
"""
Create a function to validate the bearer token of requests

The returned function should be used via fastAPI's 'Depends' mechanism to
ensure users are authenticated
"""
jwkclient = jwt.PyJWKClient(config.jwks_uri)

def validate_bearer_token(request: Request, token: UncheckedBearerToken):
"""Check that a bearer token is valid and inject into request state"""
if not token:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

signing_key = jwkclient.get_signing_key_from_jwt(token)
decoded: dict[str, Any] = jwt.decode(
token,
signing_key.key,
algorithms=config.id_token_signing_alg_values_supported,
verify=True,
audience=config.client_audience,
issuer=config.issuer,
)
request.state.decoded_access_token = decoded

return validate_bearer_token


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)]
134 changes: 134 additions & 0 deletions src/blueapi/service/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import logging
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager, aclosing, nullcontext
from typing import Annotated, Any, Self, cast

from aiohttp import ClientSession
from fastapi import Depends, HTTPException, Request
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN

from blueapi.config import OIDCConfig, OpaConfig, ServiceAccount
from blueapi.service.authentication import TiledAuth, unchecked_bearer_token
from blueapi.service.model import TaskRequest
from blueapi.utils import INSTRUMENT_SESSION_RE

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"]

@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()

async def require_tiled_service_account(self, token: str):
if not await self._call_opa(
self._config.tiled_service_account_check,
{"token": token, "beamline": self._instrument},
):
raise ValueError(
f"Tiled service account is not valid for '{self._instrument}'"
)

async def require_submit_task(self, instrument_session: str, token: str):
if not (match := INSTRUMENT_SESSION_RE.match(instrument_session)):
raise ValueError("Invalid instrument session")

if not await self._call_opa(
self._config.submit_task_check,
{
"token": token,
"proposal": int(match["proposal"]),
"visit": int(match["visit"]),
},
):
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Not authorized to submit task"
)

async def is_admin(self, token: str) -> bool:
return await self._call_opa(self._config.admin_check, {"token": token})


class OpaUserClient:
client: OpaClient
token: str

def __init__(self, client: OpaClient, token: str):
self.client = client
self.token = token

async def can_submit_task(self, task: TaskRequest):
LOGGER.info("Checking permissions to run task")
await self.client.require_submit_task(task.instrument_session, self.token)

async def admin(self) -> bool:
return await self.client.is_admin(self.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())


async def opa(
request: Request, token: str | None = Depends(unchecked_bearer_token)
) -> OpaUserClient | None:

if opa := cast(OpaClient | None, getattr(request.app.state, "authz", None)):
if not token:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED, detail="Authentication missing"
)
return OpaUserClient(opa, token)
return None


async def submit_permission(
opa: Annotated[OpaUserClient | None, Depends(opa)],
task_request: TaskRequest,
):
if opa:
await opa.can_submit_task(task_request)
Loading
Loading