Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ccdc678
Add opa dependency function to create OpaUserClient
tpoliaw May 26, 2026
f79a149
test opa dependency function
tpoliaw May 28, 2026
9ac00cc
Add can_submit_task auth check method and config
tpoliaw May 26, 2026
ac5af8c
feat: add authz dependency injection
shree-iyengar-dls May 15, 2026
663ae17
feat: add auth check dependency injections to task endpoints
shree-iyengar-dls May 18, 2026
a63192f
feat: create new access task permission fns and add as dependencies
shree-iyengar-dls May 20, 2026
c106db2
refactor: update rest api version
shree-iyengar-dls May 20, 2026
0eba9ce
comment out dependency addition in set_state
shree-iyengar-dls May 20, 2026
659872b
refactor: add admin check and check to set state function
May 20, 2026
cae87e9
Update dependency names
tpoliaw May 26, 2026
df89359
Add missing admin check
tpoliaw May 26, 2026
a63d9c1
Handle missing opa and fix tests
tpoliaw May 26, 2026
d1a62ee
Remove old admin method
tpoliaw May 26, 2026
0fbb6dd
Use starlette statuses directly
tpoliaw May 28, 2026
5c22502
test task submission authz
tpoliaw May 28, 2026
56d20c5
Use _config instead of _conf
tpoliaw Jun 5, 2026
c812969
Re-use instrument session regex
tpoliaw Jun 5, 2026
eed7160
remove task access check
tpoliaw Jun 5, 2026
4c534f7
Add match to raises check
tpoliaw Jun 5, 2026
c2449e7
Add exception detail
tpoliaw Jun 5, 2026
9d53407
Let admin see all tasks
tpoliaw Jun 5, 2026
747bce5
Start of api authz tests
tpoliaw Jun 5, 2026
cce0db1
Make get_tasks async to access authz check
tpoliaw Jun 8, 2026
8880acc
Parametrise filter test to check with and without admin
tpoliaw Jun 8, 2026
55f61a3
Add test for deleting tasks
tpoliaw Jun 8, 2026
f8d5a91
Add test for submit without permission
tpoliaw Jun 8, 2026
a7eb74a
Test setting other user's task active
tpoliaw Jun 9, 2026
760868d
Test getting other users task
tpoliaw Jun 9, 2026
4c247b9
Add tests for set state
tpoliaw Jun 9, 2026
aaa33b4
Remove print debugging
tpoliaw Jun 10, 2026
4e7885a
Make API change a minor version
tpoliaw Jun 29, 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
2 changes: 1 addition & 1 deletion docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: BlueAPI Control
version: 1.4.0
version: 1.5.0
openapi: 3.1.0
paths:
/api/v1/devices:
Expand Down
12 changes: 11 additions & 1 deletion helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,20 @@
"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"
"tiled_service_account_check",
"submit_task_check",
"admin_check"
],
"title": "OpaConfig",
"type": "object",
Expand Down
12 changes: 11 additions & 1 deletion helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -711,9 +711,15 @@
"title": "OpaConfig",
"type": "object",
"required": [
"tiled_service_account_check"
"tiled_service_account_check",
"submit_task_check",
"admin_check"
],
"properties": {
"admin_check": {
"title": "Admin Check",
"type": "string"
},
"audience": {
"title": "Audience",
"default": "account",
Expand All @@ -727,6 +733,10 @@
"maxLength": 2083,
"minLength": 1
},
"submit_task_check": {
"title": "Submit Task Check",
"type": "string"
},
"tiled_service_account_check": {
"title": "Tiled Service Account Check",
"type": "string"
Expand Down
4 changes: 3 additions & 1 deletion src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ class OpaConfig(BlueapiBaseModel):
root: HttpUrl = HttpUrl("http://localhost:8181")
audience: str = "account"
tiled_service_account_check: str
submit_task_check: str
admin_check: str
Comment thread
tpoliaw marked this conversation as resolved.


class ApplicationConfig(BlueapiBaseModel):
Expand All @@ -322,7 +324,7 @@ class ApplicationConfig(BlueapiBaseModel):
"""

#: API version to publish in OpenAPI schema
REST_API_VERSION: ClassVar[str] = "1.4.0"
REST_API_VERSION: ClassVar[str] = "1.5.0"

LICENSE_INFO: ClassVar[dict[str, str]] = {
"name": "Apache 2.0",
Expand Down
64 changes: 62 additions & 2 deletions src/blueapi/service/authorization.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import logging
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager, aclosing, nullcontext
from typing import Any, Self
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
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__)

Expand Down Expand Up @@ -56,6 +60,41 @@ async def require_tiled_service_account(self, token: str):
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
Expand All @@ -72,3 +111,24 @@ async def validate_tiled_config(
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)
73 changes: 67 additions & 6 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@
from blueapi.worker import TrackableTask, WorkerState
from blueapi.worker.event import TaskStatusEnum

from .authorization import OpaClient, validate_tiled_config
from .authorization import (
OpaClient,
OpaUserClient,
opa,
submit_permission,
validate_tiled_config,
)
from .model import (
DeviceModel,
DeviceResponse,
Expand Down Expand Up @@ -148,6 +154,37 @@ def get_app(config: ApplicationConfig):
return app


async def access_task_permission(
opa: Annotated[OpaUserClient | None, Depends(opa)],
task_id: str,
fedid: Fedid,
runner: Annotated[WorkerDispatcher, Depends(_runner)],
):
task = runner.run(interface.get_task_by_id, task_id)

if (
opa
and not await opa.admin()
and (task and fedid != task.task.metadata.get("user"))
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
Comment thread
shree-iyengar-dls marked this conversation as resolved.


# start_task_permission is used when there is WorkerTask
async def start_task_permission(
task: WorkerTask,
opa: Annotated[OpaUserClient, Depends(opa)],
fedid: Fedid,
runner: Annotated[WorkerDispatcher, Depends(_runner)],
):
if not task.task_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="No task id provided",
)
await access_task_permission(opa, task.task_id, fedid, runner)


async def on_key_error_404(_: Request, __: Exception):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
Expand Down Expand Up @@ -273,13 +310,13 @@ def submit_task(
request: Request,
response: Response,
task_request: Annotated[TaskRequest, Body(..., examples=[example_task_request])],
_: Annotated[None, Depends(submit_permission)],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
user: Fedid,
fedid: Fedid,
) -> TaskResponse:
"""Submit a task to the worker."""
try:
user = user or "Unknown"
task_id: str = runner.run(interface.submit_task, task_request, {"user": user})
task_id: str = runner.run(interface.submit_task, task_request, {"user": fedid})
response.headers["Location"] = f"{request.url}/{task_id}"
return TaskResponse(task_id=task_id)
except ValidationError as e:
Expand Down Expand Up @@ -311,6 +348,7 @@ def submit_task(
@start_as_current_span(TRACER, "task_id")
def delete_submitted_task(
task_id: str,
_: Annotated[None, Depends(access_task_permission)],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> TaskResponse:
return TaskResponse(task_id=runner.run(interface.clear_task, task_id))
Expand All @@ -327,8 +365,10 @@ def validate_task_status(v: str) -> TaskStatusEnum:
@secure_router_v1.get("/tasks", status_code=status.HTTP_200_OK, tags=[Tag.TASK])
@secure_router.get("/tasks", status_code=status.HTTP_200_OK, tags=[Tag.TASK])
@start_as_current_span(TRACER)
def get_tasks(
async def get_tasks(
fedid: Fedid,
runner: Annotated[WorkerDispatcher, Depends(_runner)],
opa: Annotated[OpaUserClient, Depends(opa)],
task_status: str | SkipJsonSchema[None] = None,
) -> TasksListResponse:
"""
Expand All @@ -348,6 +388,10 @@ def get_tasks(
tasks = runner.run(interface.get_tasks_by_status, desired_status)
else:
tasks = runner.run(interface.get_tasks)

if opa and not await opa.admin():
Comment thread
shree-iyengar-dls marked this conversation as resolved.
tasks = [t for t in tasks if t.task.metadata.get("user") == fedid]

return TasksListResponse(tasks=tasks)


Expand All @@ -365,6 +409,7 @@ def get_tasks(
def set_active_task(
request: Request,
task: WorkerTask,
_: Annotated[None, Depends(start_task_permission)],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> WorkerTask:
"""Set a task to active status, the worker should begin it as soon as possible.
Expand Down Expand Up @@ -395,6 +440,7 @@ def get_passthrough_headers(request: Request) -> dict[str, str]:
@start_as_current_span(TRACER, "task_id")
def get_task(
task_id: str,
_: Annotated[None, Depends(access_task_permission)],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> TrackableTask:
"""Retrieve a task"""
Expand Down Expand Up @@ -469,9 +515,11 @@ def get_state(runner: Annotated[WorkerDispatcher, Depends(_runner)]) -> WorkerSt
tags=[Tag.TASK],
)
@start_as_current_span(TRACER, "state_change_request.new_state")
def set_state(
async def set_state(
state_change_request: StateChangeRequest,
response: Response,
fedid: Fedid,
opa: Annotated[OpaUserClient, Depends(opa)],
runner: Annotated[WorkerDispatcher, Depends(_runner)],
) -> WorkerState:
"""
Expand All @@ -498,6 +546,19 @@ def set_state(
current_state in _ALLOWED_TRANSITIONS
and new_state in _ALLOWED_TRANSITIONS[current_state]
):
active = runner.run(interface.get_active_task)

if (
opa
and not await opa.admin()
and active
and active.task.metadata.get("user") != fedid
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to set worker state",
)

if new_state == WorkerState.PAUSED:
runner.run(interface.pause_worker, state_change_request.defer)
elif new_state == WorkerState.RUNNING:
Expand Down
3 changes: 3 additions & 0 deletions src/blueapi/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from collections.abc import Callable, Mapping
from functools import wraps
from logging import Logger
Expand Down Expand Up @@ -30,6 +31,8 @@
Args = ParamSpec("Args")
Return = TypeVar("Return")

INSTRUMENT_SESSION_RE = re.compile(r"^[a-z]{2}(?P<proposal>\d+)-(?P<visit>\d+)$")
Comment thread
tpoliaw marked this conversation as resolved.
Dismissed


def report_successful_devices(
devices: Mapping[str, Any], sim_backend: bool, logger: Logger
Expand Down
10 changes: 3 additions & 7 deletions src/blueapi/utils/serialization.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import json
import re
from typing import Any

from pydantic import BaseModel

from blueapi import utils


def serialize(obj: Any) -> Any:
"""
Expand All @@ -28,13 +29,8 @@ def serialize(obj: Any) -> Any:
return obj


_INSTRUMENT_SESSION_AUTHZ_REGEX: re.Pattern = re.compile(
r"^[a-zA-Z]{2}(?P<proposal>\d+)-(?P<visit>\d+)$"
)


def access_blob(instrument_session: str, beamline: str) -> str:
m = _INSTRUMENT_SESSION_AUTHZ_REGEX.match(instrument_session)
m = utils.INSTRUMENT_SESSION_RE.match(instrument_session)
if m is None:
raise ValueError(
"Unable to extract proposal and visit from "
Expand Down
Loading
Loading