diff --git a/Dockerfile b/Dockerfile index a51162f..1d64146 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # The devcontainer should use the developer target and run as root with podman # or docker with user namespaces. -FROM ghcr.io/diamondlightsource/ubuntu-devcontainer:noble AS developer +FROM ghcr.io/diamondlightsource/ubuntu-devcontainer:resolute AS developer # Add any system dependencies for the developer/build environment here RUN apt-get update -y && apt-get install -y --no-install-recommends \ @@ -54,6 +54,11 @@ FROM ubuntu:resolute AS runtime # some-library \ # && apt-get dist-clean +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + update-ca-certificates && \ + apt-get dist-clean + # Copy the python installation from the build stage COPY --from=build /python /python diff --git a/docs/reference/rest_api.json b/docs/reference/rest_api.json index 48a1ce6..c9d39c1 100644 --- a/docs/reference/rest_api.json +++ b/docs/reference/rest_api.json @@ -5,22 +5,6 @@ "version": "0.1.0" }, "paths": { - "/healthz": { - "get": { - "summary": "Healthz", - "operationId": "healthz_healthz_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, "/": { "get": { "summary": "Read Root", @@ -37,18 +21,16 @@ } } }, - "/config": { + "/healthz": { "get": { - "summary": "Get Config", - "operationId": "get_config_config_get", + "summary": "Healthz", + "operationId": "healthz_healthz_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/AppConfig" - } + "schema": {} } } } @@ -109,6 +91,24 @@ } } }, + "/config": { + "get": { + "summary": "Get Config", + "operationId": "get_config_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppConfig" + } + } + } + } + } + } + }, "/queue": { "get": { "summary": "Get Queued Tasks", @@ -605,45 +605,10 @@ "AppConfig": { "properties": { "blueapi": { - "$ref": "#/components/schemas/ApplicationConfig" + "$ref": "#/components/schemas/BlueapiConfig" }, "converter": { "$ref": "#/components/schemas/ConverterConfig" - } - }, - "type": "object", - "required": [ - "blueapi", - "converter" - ], - "title": "AppConfig" - }, - "ApplicationConfig": { - "properties": { - "stomp": { - "$ref": "#/components/schemas/StompConfig" - }, - "tiled": { - "$ref": "#/components/schemas/TiledConfig" - }, - "env": { - "$ref": "#/components/schemas/EnvironmentConfig" - }, - "logging": { - "$ref": "#/components/schemas/LoggingConfig" - }, - "api": { - "$ref": "#/components/schemas/RestConfig" - }, - "scratch": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScratchConfig" - }, - { - "type": "null" - } - ] }, "oidc": { "anyOf": [ @@ -655,43 +620,27 @@ } ] }, - "auth_token_path": { + "authorisation_whitelist": { "anyOf": [ { - "type": "string", - "format": "path" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], - "title": "Auth Token Path" - }, - "numtracker": { - "anyOf": [ - { - "$ref": "#/components/schemas/NumtrackerConfig" - }, - { - "type": "null" - } - ] - }, - "opa": { - "anyOf": [ - { - "$ref": "#/components/schemas/OpaConfig" - }, - { - "type": "null" - } - ] + "title": "Authorisation Whitelist" } }, - "additionalProperties": false, "type": "object", - "title": "ApplicationConfig", - "description": "Config for the worker application as a whole. Root of\nconfig tree." + "required": [ + "blueapi", + "converter" + ], + "title": "AppConfig" }, "BasicAuthentication": { "properties": { @@ -805,6 +754,18 @@ ], "title": "BlueapiCallResponse" }, + "BlueapiConfig": { + "properties": { + "stomp": { + "$ref": "#/components/schemas/StompConfig" + }, + "api": { + "$ref": "#/components/schemas/RestConfig" + } + }, + "type": "object", + "title": "BlueapiConfig" + }, "CORSConfig": { "properties": { "origins": { @@ -877,82 +838,6 @@ ], "title": "ConverterConfig" }, - "DeviceManagerSource": { - "properties": { - "module": { - "type": "string", - "title": "Module", - "description": "Module to be imported" - }, - "kind": { - "type": "string", - "const": "deviceManager", - "title": "Kind", - "default": "deviceManager" - }, - "mock": { - "type": "boolean", - "title": "Mock", - "description": "If true, ophyd_async device connections are mocked", - "default": false - }, - "name": { - "type": "string", - "title": "Name", - "description": "Name of the device manager in the module", - "default": "devices" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "module" - ], - "title": "DeviceManagerSource" - }, - "EnvironmentConfig": { - "properties": { - "sources": { - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/PlanSource" - }, - { - "$ref": "#/components/schemas/DeviceManagerSource" - } - ], - "discriminator": { - "propertyName": "kind", - "mapping": { - "deviceManager": "#/components/schemas/DeviceManagerSource", - "planFunctions": "#/components/schemas/PlanSource" - } - } - }, - "type": "array", - "title": "Sources", - "default": [] - }, - "events": { - "$ref": "#/components/schemas/WorkerEventConfig" - }, - "metadata": { - "anyOf": [ - { - "$ref": "#/components/schemas/MetadataConfig" - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false, - "type": "object", - "title": "EnvironmentConfig", - "description": "Config for the RunEngine environment" - }, "Experiment": { "properties": { "name": { @@ -1003,25 +888,6 @@ ], "title": "ExperimentDefinition" }, - "GraylogConfig": { - "properties": { - "enabled": { - "type": "boolean", - "title": "Enabled", - "default": false - }, - "url": { - "type": "string", - "minLength": 1, - "format": "uri", - "title": "Url", - "default": "tcp://localhost:5555" - } - }, - "additionalProperties": false, - "type": "object", - "title": "GraylogConfig" - }, "HTTPValidationError": { "properties": { "detail": { @@ -1035,67 +901,6 @@ "type": "object", "title": "HTTPValidationError" }, - "LoggingConfig": { - "properties": { - "level": { - "type": "string", - "enum": [ - "NOTSET", - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL" - ], - "title": "Level", - "default": "INFO" - }, - "graylog": { - "$ref": "#/components/schemas/GraylogConfig", - "default": { - "enabled": false, - "url": "tcp://localhost:5555" - } - } - }, - "additionalProperties": false, - "type": "object", - "title": "LoggingConfig" - }, - "MetadataConfig": { - "properties": { - "instrument": { - "type": "string", - "title": "Instrument" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "instrument" - ], - "title": "MetadataConfig" - }, - "NumtrackerConfig": { - "properties": { - "url": { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri", - "title": "Url", - "default": "http://localhost:8406/graphql" - }, - "detector_file_template": { - "type": "string", - "title": "Detector File Template", - "default": "{instrument}-{scan_id}-{device_name}" - } - }, - "additionalProperties": false, - "type": "object", - "title": "NumtrackerConfig" - }, "OIDCConfig": { "properties": { "well_known_url": { @@ -1148,33 +953,6 @@ ], "title": "OIDCConfig" }, - "OpaConfig": { - "properties": { - "root": { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri", - "title": "Root", - "default": "http://localhost:8181/" - }, - "audience": { - "type": "string", - "title": "Audience", - "default": "account" - }, - "tiled_service_account_check": { - "type": "string", - "title": "Tiled Service Account Check" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "tiled_service_account_check" - ], - "title": "OpaConfig" - }, "PauseReason": { "type": "string", "enum": [ @@ -1184,27 +962,6 @@ ], "title": "PauseReason" }, - "PlanSource": { - "properties": { - "module": { - "type": "string", - "title": "Module", - "description": "Module to be imported" - }, - "kind": { - "type": "string", - "const": "planFunctions", - "title": "Kind", - "default": "planFunctions" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "module" - ], - "title": "PlanSource" - }, "QueueState": { "properties": { "paused": { @@ -1284,85 +1041,6 @@ ], "title": "Sample" }, - "ScratchConfig": { - "properties": { - "root": { - "type": "string", - "format": "path", - "title": "Root", - "description": "The root directory of the scratch area, all repositories will be cloned under this directory.", - "default": "/tmp/scratch/blueapi" - }, - "required_gid": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Required Gid", - "description": "\nRequired owner GID for the scratch directory. If supplied, the setup-scratch\ncommand will check the scratch area ownership and raise an error if it is\nnot owned by , or if it does not have SGID permission bit set.\n" - }, - "repositories": { - "items": { - "$ref": "#/components/schemas/ScratchRepository" - }, - "type": "array", - "title": "Repositories", - "description": "Details of repositories to be cloned and imported into blueapi" - } - }, - "additionalProperties": false, - "type": "object", - "title": "ScratchConfig" - }, - "ScratchRepository": { - "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "Unique name for this repository in the scratch directory", - "default": "example" - }, - "remote_url": { - "type": "string", - "title": "Remote Url", - "description": "URL to clone from", - "default": "https://github.com/example/example.git" - }, - "target_revision": { - "type": "string", - "title": "Target Revision", - "description": "Revision (branch or tag) to check out when cloning - defaults to remote's HEAD. If a tag is used, the repo will be left in a 'detached head' state." - } - }, - "additionalProperties": false, - "type": "object", - "title": "ScratchRepository" - }, - "ServiceAccount": { - "properties": { - "client_id": { - "type": "string", - "title": "Client Id", - "description": "Service account client ID", - "default": "" - }, - "client_secret": { - "type": "string", - "format": "password", - "title": "Client Secret", - "description": "Service account client secret", - "default": "", - "writeOnly": true - } - }, - "additionalProperties": false, - "type": "object", - "title": "ServiceAccount" - }, "Status": { "type": "string", "enum": [ @@ -1561,42 +1239,6 @@ ], "title": "TaskWithPosition" }, - "TiledConfig": { - "properties": { - "enabled": { - "type": "boolean", - "title": "Enabled", - "description": "True if blueapi should forward data to a Tiled instance", - "default": false - }, - "url": { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri", - "title": "Url", - "default": "http://localhost:8407/" - }, - "authentication": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/components/schemas/ServiceAccount" - }, - { - "type": "null" - } - ], - "title": "Authentication", - "description": "Tiled Authentication can be API_KEY or OIDC Service account" - } - }, - "additionalProperties": false, - "type": "object", - "title": "TiledConfig" - }, "ValidationError": { "properties": { "loc": { @@ -1636,19 +1278,6 @@ "type" ], "title": "ValidationError" - }, - "WorkerEventConfig": { - "properties": { - "broadcast_status_events": { - "type": "boolean", - "title": "Broadcast Status Events", - "default": true - } - }, - "additionalProperties": false, - "type": "object", - "title": "WorkerEventConfig", - "description": "Config for event broadcasting via the message bus" } } } diff --git a/pyproject.toml b/pyproject.toml index 4291de8..99ebdf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,10 @@ classifiers = [ ] description = "A service to queue tasks and chain BlueAPI calls" dependencies = [ - "blueapi>=1.18.0", + "blueapi>=1.18.1", "fastapi>=0.136.0", "pydantic>=2.13.2", - "tiled>=0.2.9", + "tiled[client]>=0.2.9", ] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/daq_queuing_service/__main__.py b/src/daq_queuing_service/__main__.py index 7a09e3a..035b213 100644 --- a/src/daq_queuing_service/__main__.py +++ b/src/daq_queuing_service/__main__.py @@ -6,7 +6,7 @@ import uvicorn -from daq_queuing_service.app._config import get_default_config +from daq_queuing_service.app._config import get_default_config_path from . import __version__ @@ -19,7 +19,7 @@ def main(args: Sequence[str] | None = None) -> None: parser.add_argument("-v", "--version", action="version", version=__version__) parser.add_argument("-p", "--port", type=int, default=8000) parser.add_argument("--dev", action="store_true", default=False) - parser.add_argument("--config", type=Path, default=get_default_config()) + parser.add_argument("--config", type=Path, default=get_default_config_path()) parsed_args = parser.parse_args(args) diff --git a/src/daq_queuing_service/api/api.py b/src/daq_queuing_service/api/api.py index 4320e7c..cd8573e 100644 --- a/src/daq_queuing_service/api/api.py +++ b/src/daq_queuing_service/api/api.py @@ -1,13 +1,15 @@ import asyncio import json from collections.abc import AsyncGenerator +from typing import Annotated from blueapi.service.model import TaskRequest -from fastapi import APIRouter, Request, Response +from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import EventSourceResponse from pydantic import BaseModel from daq_queuing_service.app._config import AppConfig +from daq_queuing_service.app.authentication import User from daq_queuing_service.blueapi_interaction.blueapi_call import BlueapiCallResponse from daq_queuing_service.broadcaster import Broadcaster from daq_queuing_service.plugins.converter import Converter, ValidateError @@ -23,6 +25,14 @@ # pyright: reportUnusedFunction=false +def get_current_user(request: Request) -> User | None: + if hasattr(request.state, "user"): + return request.state.user + + +CurrentUser = Annotated[User | None, Depends(get_current_user)] + + class QueueStateUpdate(BaseModel): paused: bool @@ -39,18 +49,10 @@ def _filter_by_status( return [task for task in tasks if task.status == status] -def create_api_router( - queue: TaskQueue, - broadcaster: Broadcaster[QUEUE_EVENTS], - config: AppConfig, - converter: Converter, -) -> APIRouter: +def public_routes(queue: TaskQueue) -> APIRouter: + """No authentication is required to access these endpoints.""" router = APIRouter() - @router.get("/healthz") - async def healthz(): - return Response() - @router.get("/") def read_root(request: Request): base_url = str(request.base_url) @@ -58,6 +60,29 @@ def read_root(request: Request): f"Welcome to the daq queuing service. Visit {base_url}docs for Uvicorn API." ) + @router.get("/healthz") + async def healthz(): + return Response() + + @router.get("/queue/state") + def get_queue_state() -> QueueState: + return queue.state + + return router + + +def protected_routes( + queue: TaskQueue, + broadcaster: Broadcaster[QUEUE_EVENTS], + config: AppConfig, + converter: Converter, +) -> APIRouter: + """Authentication is required to access these endpoints (if turned on in config). + Additionally, for endpoints that depend on whitelist_check, you must be in the + whitelist of authorised fedIDs to access them. + """ + router = APIRouter() + @router.get("/config") def get_config() -> AppConfig: return config @@ -69,10 +94,6 @@ async def update_queue_state(payload: QueueStateUpdate) -> QueueState: else: return await queue.resume_queue() - @router.get("/queue/state") - def get_queue_state() -> QueueState: - return queue.state - @router.get("/queue") async def get_queued_tasks(status: Status | None = None) -> list[TaskWithPosition]: return _filter_by_status(await queue.get_queue(), status) @@ -80,6 +101,7 @@ async def get_queued_tasks(status: Status | None = None) -> list[TaskWithPositio @router.post("/queue") async def add_tasks_to_queue( experiments: list[TaskRequest | Experiment], + user: CurrentUser, position: int | None = None, ) -> list[str]: try: @@ -87,7 +109,7 @@ async def add_tasks_to_queue( except Exception as e: raise ValidateError(*e.args) from e - tasks = [Task(experiment=experiment) for experiment in experiments] + tasks = [Task(experiment=experiment, user=user) for experiment in experiments] task_ids = [task.id for task in tasks] await queue.add_tasks(tasks, position) return task_ids diff --git a/src/daq_queuing_service/app/_config.py b/src/daq_queuing_service/app/_config.py index c3706ae..79167d6 100644 --- a/src/daq_queuing_service/app/_config.py +++ b/src/daq_queuing_service/app/_config.py @@ -2,8 +2,8 @@ from pathlib import Path import yaml -from blueapi.config import ApplicationConfig -from pydantic import BaseModel +from blueapi.config import OIDCConfig, RestConfig, StompConfig +from pydantic import BaseModel, Field CONFIG_PATH = "/etc/config/config.yaml" TEST_CONFIG_PATH = "tests/test_data/test_config.yaml" @@ -14,12 +14,19 @@ class ConverterConfig(BaseModel): name: str +class BlueapiConfig(BaseModel): + stomp: StompConfig = Field(default_factory=StompConfig) + api: RestConfig = Field(default_factory=RestConfig) + + class AppConfig(BaseModel): - blueapi: ApplicationConfig + blueapi: BlueapiConfig converter: ConverterConfig + oidc: OIDCConfig | None = None + authorisation_whitelist: list[str] | None = None -def get_default_config() -> str: +def get_default_config_path() -> str: return CONFIG_PATH if os.path.isfile(CONFIG_PATH) else TEST_CONFIG_PATH diff --git a/src/daq_queuing_service/app/app.py b/src/daq_queuing_service/app/app.py index bd6d5bb..86b0d79 100644 --- a/src/daq_queuing_service/app/app.py +++ b/src/daq_queuing_service/app/app.py @@ -6,9 +6,18 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.param_functions import Depends +from fastapi.params import Depends as DependsType -from daq_queuing_service.api.api import create_api_router +from daq_queuing_service.api.api import protected_routes, public_routes from daq_queuing_service.api.errors import register_exception_handlers +from daq_queuing_service.app.authentication import ( + build_access_token_check, + build_get_current_user, +) +from daq_queuing_service.app.authorisation import ( + build_ensure_current_user_is_in_whitelist, +) from daq_queuing_service.blueapi_interaction.blueapi_adapter import BlueapiClientAdapter from daq_queuing_service.blueapi_interaction.get_client import get_blueapi_client from daq_queuing_service.broadcaster import Broadcaster @@ -54,6 +63,23 @@ def log_task_exception(task: asyncio.Task[NoReturn]): app = FastAPI(lifespan=lifespan) + dependencies: list[DependsType] = [] + whitelist_check = None + if config.oidc: + validate_token = build_access_token_check(config.oidc) + get_current_user = build_get_current_user(validate_token) + + app.swagger_ui_init_oauth = { + "clientId": "NOT_SUPPORTED", + } + + whitelist_check = build_ensure_current_user_is_in_whitelist( + config.authorisation_whitelist, get_current_user + ) + + dependencies.append(Depends(get_current_user)) + dependencies.append(Depends(whitelist_check)) + if dev: # Allows local client/UI through CORS app.add_middleware( CORSMiddleware, @@ -74,8 +100,10 @@ def log_task_exception(task: asyncio.Task[NoReturn]): ) register_exception_handlers(app) + app.include_router(public_routes(app.state.queue)) app.include_router( - create_api_router(app.state.queue, broadcaster, config, converter) + protected_routes(app.state.queue, broadcaster, config, converter), + dependencies=dependencies, ) return app diff --git a/src/daq_queuing_service/app/authentication.py b/src/daq_queuing_service/app/authentication.py new file mode 100644 index 0000000..a287afa --- /dev/null +++ b/src/daq_queuing_service/app/authentication.py @@ -0,0 +1,103 @@ +from collections.abc import Callable +from typing import Annotated, Any + +import jwt +from blueapi.config import OIDCConfig +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jwt.exceptions import DecodeError, ExpiredSignatureError +from pydantic import BaseModel, ValidationError +from starlette.status import HTTP_401_UNAUTHORIZED + +from daq_queuing_service.log import LOGGER + + +class User(BaseModel): + fedid: str + email: str | None = None + name: str | None = None + + +# Some of the following code was copied from blueapi +# See https://github.com/DiamondLightSource/blueapi/blob/2108ee0c89b4399d961106f7f23082a58d48a564/src/blueapi/service/authentication.py#L281-L340 + +bearer_scheme = HTTPBearer(auto_error=False) + + +def unchecked_bearer_token( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)], +) -> str | None: + if credentials is None: + return None + return credentials.credentials + + +UncheckedBearerToken = Annotated[str | None, Depends(unchecked_bearer_token)] + + +def build_access_token_check( + config: OIDCConfig, +) -> Callable[[UncheckedBearerToken], dict[str, Any]]: + """ + 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 + """ + LOGGER.info(f"JWKS URI: {config.jwks_uri}") + jwkclient = jwt.PyJWKClient(config.jwks_uri) + + def validate_bearer_token(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"}, + ) + + try: + signing_key = jwkclient.get_signing_key_from_jwt(token) + except DecodeError as e: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Cannot decode token", + ) from e + except ExpiredSignatureError as e: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Token expired", + ) from e + + 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, + ) + LOGGER.debug(f"Decoded valid token: {decoded}") + return decoded + + return validate_bearer_token + + +def build_get_current_user( + validate_token: Callable[..., dict[str, Any]], +) -> Callable[[Request, dict[str, Any]], User]: + def get_current_user( + request: Request, + decoded: Annotated[dict[str, Any], Depends(validate_token)], + ) -> User: + try: + user = User.model_validate(decoded) + except ValidationError as e: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid token claims", + ) from e + request.state.user = user + return user + + return get_current_user diff --git a/src/daq_queuing_service/app/authorisation.py b/src/daq_queuing_service/app/authorisation.py new file mode 100644 index 0000000..7bd5da0 --- /dev/null +++ b/src/daq_queuing_service/app/authorisation.py @@ -0,0 +1,31 @@ +from collections.abc import Callable +from typing import Annotated + +from fastapi import Depends, HTTPException +from starlette.status import HTTP_403_FORBIDDEN + +from daq_queuing_service.app.authentication import User +from daq_queuing_service.worker.worker import LOGGER + + +def build_ensure_current_user_is_in_whitelist( + whitelist: list[str] | None, get_current_user: Callable[..., User] +) -> Callable[[User], User]: + def ensure_current_user_is_in_whitelist( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + LOGGER.debug(f"Got user: {current_user}") + if whitelist is None: + LOGGER.debug("No user whitelist. All authenticated users are authorised.") + return current_user + elif current_user.fedid in whitelist: + LOGGER.debug( + f"FedID {current_user.fedid} found in whitelist, user authorised." + ) + return current_user + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Not authorised. You are not in the whitelist of authorised FedIDs", + ) + + return ensure_current_user_is_in_whitelist diff --git a/src/daq_queuing_service/blueapi_interaction/get_client.py b/src/daq_queuing_service/blueapi_interaction/get_client.py index 79647c6..75f7191 100644 --- a/src/daq_queuing_service/blueapi_interaction/get_client.py +++ b/src/daq_queuing_service/blueapi_interaction/get_client.py @@ -1,18 +1,13 @@ -from unittest.mock import MagicMock - from blueapi.client import BlueapiClient from blueapi.client.event_bus import EventBusClient from blueapi.client.rest import BlueapiRestClient -from blueapi.config import ApplicationConfig from bluesky_stomp.messaging import Broker, StompClient +from daq_queuing_service.app._config import BlueapiConfig from daq_queuing_service.blueapi_interaction.token_retriever import UDCTokenRetriever -def get_blueapi_client(blueapi_config: ApplicationConfig) -> BlueapiClient: - if not blueapi_config.oidc: - blueapi_config.oidc = MagicMock() - +def get_blueapi_client(blueapi_config: BlueapiConfig) -> BlueapiClient: blueapi_rest_client = BlueapiRestClient( config=blueapi_config.api, # Waiting on https://github.com/DiamondLightSource/blueapi/pull/1553 diff --git a/src/daq_queuing_service/task_queue/task.py b/src/daq_queuing_service/task_queue/task.py index 016a187..205b3c1 100644 --- a/src/daq_queuing_service/task_queue/task.py +++ b/src/daq_queuing_service/task_queue/task.py @@ -5,6 +5,7 @@ from blueapi.service.model import TaskRequest from pydantic import BaseModel, Field, computed_field +from daq_queuing_service.app.authentication import User from daq_queuing_service.blueapi_interaction.blueapi_call import ( BlueapiCall, BlueapiCallResponse, @@ -52,6 +53,7 @@ class Task(BaseModel): experiment: Experiment | TaskRequest id: str = Field(default_factory=create_uuid_str) blueapi_calls: list[BlueapiCall] = Field(default_factory=lambda: []) + user: User | None = None _cancelled: bool = False def cancel(self): @@ -97,6 +99,7 @@ class TaskWithPosition(BaseModel): blueapi_calls: list[BlueapiCallResponse] position: int | None kind: TaskKind + user: User | None @classmethod def from_task(cls, task: Task, position: int | None = None) -> Self: diff --git a/tests/constants.py b/tests/constants.py new file mode 100644 index 0000000..7a8b2c1 --- /dev/null +++ b/tests/constants.py @@ -0,0 +1,2 @@ +TEST_CONFIG_PATH = "tests/test_data/test_config.yaml" +TEST_CONFIG_WITH_AUTH_PATH = "tests/test_data/test_config_with_auth.yaml" diff --git a/tests/system_tests/config_with_auth.yaml b/tests/system_tests/config_with_auth.yaml new file mode 100644 index 0000000..f8221c7 --- /dev/null +++ b/tests/system_tests/config_with_auth.yaml @@ -0,0 +1,16 @@ +converter: + path: "daq_queuing_service.plugins.converter" + name: "Converter" +blueapi: + api: + url: "http://localhost:8000" + stomp: + enabled: true # All other stomp settings will be ignored if this is false + url: tcp://localhost:61613 + auth: + username: guest + password: guest +oidc: + issuer: "https://identity.diamond.ac.uk/realms/dls" + client_id: "daq-queuing-service" + client_audience: "account" diff --git a/tests/test_data/test_config_with_auth.yaml b/tests/test_data/test_config_with_auth.yaml new file mode 100644 index 0000000..bd48528 --- /dev/null +++ b/tests/test_data/test_config_with_auth.yaml @@ -0,0 +1,18 @@ +converter: + path: "daq_queuing_service.plugins.converter" + name: "Converter" +blueapi: + api: + url: "http://localhost:8000" + stomp: + enabled: true # All other stomp settings will be ignored if this is false + url: tcp://localhost:61613 + auth: + username: guest + password: guest +oidc: + issuer: "https://example.com" + client_id: "daq-queuing-service" + client_audience: "account" +authorisation_whitelist: + - "abc12345" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 77f2989..fc50946 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -1,8 +1,20 @@ +from functools import cached_property +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + import pytest +from blueapi.config import OIDCConfig from blueapi.service.model import TaskRequest from blueapi.worker.event import TaskError, TaskResult +from fastapi import FastAPI +from fastapi.dependencies.models import Dependant from pytest import MonkeyPatch +from constants import TEST_CONFIG_WITH_AUTH_PATH +from daq_queuing_service.app._config import AppConfig, load_config +from daq_queuing_service.app.app import create_app +from daq_queuing_service.app.authentication import User from daq_queuing_service.blueapi_interaction.blueapi_call import BlueapiCall from daq_queuing_service.broadcaster import Broadcaster from daq_queuing_service.log import LOGGER @@ -154,3 +166,53 @@ def _construct_blueapi_task_request( ) return DNConverter() + + +@pytest.fixture +def oidc_config(): + class MockOIDCConfig(OIDCConfig): + @cached_property + def _config_from_oidc_url(self) -> dict[str, Any]: + # This would usually make a real request, we don't want this in tests + return {} + + config = load_config(Path(TEST_CONFIG_WITH_AUTH_PATH)) + assert config.oidc is not None + return MockOIDCConfig.model_validate(config.oidc.model_dump()) + + +@pytest.fixture +def use_config_with_auth(oidc_config: OIDCConfig): + + config = load_config(Path(TEST_CONFIG_WITH_AUTH_PATH)) + config.oidc = oidc_config + with patch("daq_queuing_service.app.app.load_config", return_value=config): + yield config + + +@pytest.fixture +def app_with_auth(use_config_with_auth: AppConfig) -> FastAPI: + return create_app(Path("")) + + +@pytest.fixture +def app_with_authz(use_config_with_auth: AppConfig): + """Authentication always passes. Only user abc12345 is authorised""" + + def fake_get_current_user(): + return User(fedid="xyz54321") + + with patch( + "daq_queuing_service.app.app.build_get_current_user", + MagicMock(return_value=fake_get_current_user), + ): + app = create_app(Path("")) + yield app + return app + + +def has_dependency_name(dep: Dependant, name: str) -> bool: + if getattr(dep.call, "__name__", None) == name: + return True + + return any(has_dependency_name(child, name) for child in dep.dependencies) diff --git a/tests/unit_tests/plugins/i15-1/test_i15_1_converter.py b/tests/unit_tests/plugins/i15-1/test_i15_1_converter.py index 282c368..9d10227 100644 --- a/tests/unit_tests/plugins/i15-1/test_i15_1_converter.py +++ b/tests/unit_tests/plugins/i15-1/test_i15_1_converter.py @@ -132,6 +132,7 @@ def test_experiment_with_correct_experiment_type_are_converted(): blueapi_calls=[], position=None, kind=TaskKind.EXPERIMENT, + user=None, ) call_list = I151Converter().construct_blueapi_calls([task], [], []) assert len(call_list) == 3 @@ -153,6 +154,7 @@ def test_mix_of_experiments_with_correct_experiment_type_are_converted(): blueapi_calls=[], position=None, kind=TaskKind.EXPERIMENT, + user=None, ) class BadExperiment: @@ -201,6 +203,7 @@ def test_if_no_background_found_in_tiled_then_background_scan_added_to_tasks( "blueapi_calls": [], "status": Status.QUEUED, "kind": TaskKind.EXPERIMENT, + "user": None, } @@ -281,6 +284,7 @@ def test_same_experiment_in_different_instrument_sessions_will_add_background_in "blueapi_calls": [], "status": Status.QUEUED, "kind": TaskKind.EXPERIMENT, + "user": None, } new_tasks[2].id = "" assert new_tasks[2].model_dump() == { @@ -298,6 +302,7 @@ def test_same_experiment_in_different_instrument_sessions_will_add_background_in "blueapi_calls": [], "status": Status.QUEUED, "kind": TaskKind.EXPERIMENT, + "user": None, } diff --git a/tests/unit_tests/test_api.py b/tests/unit_tests/test_api.py index 5882c7a..b3296c3 100644 --- a/tests/unit_tests/test_api.py +++ b/tests/unit_tests/test_api.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import MagicMock, patch +import httpx import pytest from blueapi.client.rest import ( BlueapiRestClient, @@ -17,12 +18,16 @@ from fastapi.encoders import jsonable_encoder from fastapi.testclient import TestClient +from constants import TEST_CONFIG_PATH from daq_queuing_service.api.api import ( TaskCancelRequest, - create_api_router, + get_current_user, + protected_routes, + public_routes, ) from daq_queuing_service.api.errors import register_exception_handlers -from daq_queuing_service.app._config import TEST_CONFIG_PATH, load_config +from daq_queuing_service.app._config import load_config +from daq_queuing_service.app.authentication import User from daq_queuing_service.blueapi_interaction.blueapi_call import ( BlueapiCall, BlueapiCallResponse, @@ -61,14 +66,14 @@ def broadcaster() -> Broadcaster[QUEUE_EVENTS]: @pytest.fixture def app( task_queue_with_history: TaskQueue, - blueapi_client: BlueapiRestClient, broadcaster: Broadcaster[QUEUE_EVENTS], converter: Converter, ) -> FastAPI: app = FastAPI() register_exception_handlers(app) + app.include_router(public_routes(task_queue_with_history)) app.include_router( - create_api_router( + protected_routes( task_queue_with_history, broadcaster, load_config(Path(TEST_CONFIG_PATH)), @@ -83,6 +88,17 @@ def test_client(app: FastAPI) -> TestClient: return TestClient(app) +@pytest.fixture +def test_client_with_auth(app_with_auth: FastAPI) -> TestClient: + return TestClient(app_with_auth) + + +@pytest.fixture +def test_client_with_authz(app_with_authz: FastAPI) -> TestClient: + """Authentication always passes. Only user abc12345 is authorised""" + return TestClient(app_with_authz) + + def test_read_root_returns_expected_string(test_client: TestClient): response = test_client.get("/") assert response.status_code == 200 @@ -160,6 +176,7 @@ def test_get_queued_tasks_returns_queued_task(test_client: TestClient): ], "position": 0, "kind": "Experiment", + "user": None, }, { "experiment": { @@ -188,6 +205,7 @@ def test_get_queued_tasks_returns_queued_task(test_client: TestClient): ], "position": 1, "kind": "Experiment", + "user": None, }, { "experiment": { @@ -216,6 +234,7 @@ def test_get_queued_tasks_returns_queued_task(test_client: TestClient): ], "position": 2, "kind": "Experiment", + "user": None, }, ] @@ -251,6 +270,7 @@ def test_get_queued_tasks_can_filter_by_task_status(test_client: TestClient): ], "position": 0, "kind": "Experiment", + "user": None, } ] @@ -300,6 +320,7 @@ async def test_get_all_tasks_can_filter_by_task_status(test_client: TestClient): ], "position": None, "kind": "Experiment", + "user": None, } ] @@ -356,9 +377,32 @@ async def test_add_tasks_to_queue_adds_to_queue_and_and_returns_task_ids( position=3, status=Status.QUEUED, kind=TaskKind.PLAN, + user=None, ) +async def test_add_tasks_to_queue_adds_user_to_task_object( + app: FastAPI, task_queue_with_history: TaskQueue +): + user = User(fedid="abc12345", email="joe.blogs@diamond.ac.uk", name="Joe Blogs") + app.dependency_overrides[get_current_user] = lambda: user + test_client = TestClient(app) + + task_id = test_client.post( + "/queue", + json=[ + { + "name": "add_tasks", + "params": {"time": 10}, + "instrument_session": "abc", + } + ], + ).json()[0] + task = await task_queue_with_history.get_task_by_id(task_id) + assert task + assert task.user == user + + async def test_add_tasks_to_queue_validates_new_tasks_and_gives_expected_error_if_fails( test_client: TestClient, task_queue_with_history: TaskQueue, converter: Converter ): @@ -606,6 +650,7 @@ async def test_cancel_tasks_removes_task_from_queue_and_returns_tasks( ], "position": None, "kind": "Experiment", + "user": None, }, { "experiment": { @@ -634,6 +679,7 @@ async def test_cancel_tasks_removes_task_from_queue_and_returns_tasks( ], "position": None, "kind": "Experiment", + "user": None, }, ] @@ -734,6 +780,7 @@ async def test_cancel_all_tasks_removes_all_queued_tasks_from_queue_and_returns_ ], "position": None, "kind": "Experiment", + "user": None, }, { "experiment": { @@ -762,6 +809,7 @@ async def test_cancel_all_tasks_removes_all_queued_tasks_from_queue_and_returns_ ], "position": None, "kind": "Experiment", + "user": None, }, ] @@ -796,6 +844,7 @@ def test_get_task_by_position_returns_expected_task(test_client: TestClient): ], "position": 1, "kind": "Experiment", + "user": None, } @@ -829,6 +878,7 @@ def test_get_task_by_id_returns_expected_task(test_client: TestClient): ], "position": 1, "kind": "Experiment", + "user": None, } @@ -926,3 +976,79 @@ def read_stream(): time.sleep(0.2) assert len(received) == 5 assert received != [] + + +@pytest.mark.parametrize( + "endpoint, method", + [ + ("/config", "get"), + ("/queue/state", "patch"), + ("/queue", "get"), + ("/queue", "post"), + ("/queue", "delete"), + ("/queue/move", "post"), + ("/queue/tasks", "delete"), + ("/queue/5", "get"), + ("/tasks", "get"), + ("/tasks/task_id", "get"), + ("/history", "get"), + ("/history", "delete"), + ("/call_queue", "get"), + ("/call_history", "get"), + ("/events", "get"), + ], +) +def test_endpoints_blocked_by_authentication_check_if_no_token_provided( + endpoint: str, method: str, test_client_with_auth: TestClient +): + response: httpx.Response = getattr(test_client_with_auth, method)(endpoint) + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +@pytest.mark.parametrize( + "endpoint, method", + [("/", "get"), ("/healthz", "get"), ("/queue/state", "get")], +) +def test_public_endpoints_not_blocked_by_auth( + endpoint: str, method: str, test_client_with_auth: TestClient +): + response: httpx.Response = getattr(test_client_with_auth, method)(endpoint) + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "endpoint, method", + [ + ("/config", "get"), + ("/queue/state", "patch"), + ("/queue", "get"), + ("/queue", "post"), + ("/queue", "delete"), + ("/queue/move", "post"), + ("/queue/tasks", "delete"), + ("/queue/5", "get"), + ("/tasks", "get"), + ("/tasks/task_id", "get"), + ("/history", "get"), + ("/history", "delete"), + ("/call_queue", "get"), + ("/call_history", "get"), + ("/events", "get"), + ], +) +def test_endpoints_blocked_by_authorisation_check_if_user_not_in_whitelist( + endpoint: str, method: str, test_client_with_authz: TestClient +): + response: httpx.Response = getattr(test_client_with_authz, method)(endpoint) + assert response.status_code == 403 + assert response.json() == { + "detail": "Not authorised. You are not in the whitelist of authorised FedIDs" + } + + +def test_get_current_user_returns_user_from_request_state(): + request = MagicMock() + user = User(fedid="abc12345") + request.state.user = user + assert get_current_user(request) == user diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 28c96a5..a49bc3b 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -2,27 +2,19 @@ import logging from pathlib import Path from typing import NoReturn -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -import pytest from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.routing import APIRoute from fastapi.testclient import TestClient from pytest import LogCaptureFixture -from daq_queuing_service.app._config import TEST_CONFIG_PATH +from constants import TEST_CONFIG_PATH from daq_queuing_service.app.app import create_app from daq_queuing_service.task_queue.queue import TaskQueue from daq_queuing_service.worker.worker import QueueWorker - - -@pytest.fixture(autouse=True) -def patch_config_path(): - with patch( - "daq_queuing_service.app._config.CONFIG_PATH", - "tests/test_data/test_config.yaml", - ): - yield +from unit_tests.conftest import has_dependency_name def test_create_app_returns_fast_api_object(): @@ -39,13 +31,15 @@ def test_create_app_registers_exception_handlers(): mock_register_exception_handlers.assert_called_once() -def test_create_app_adds_router(): - with patch( - "daq_queuing_service.app.app.create_api_router" - ) as mock_create_api_router: - create_app(Path(TEST_CONFIG_PATH)) +@patch("daq_queuing_service.app.app.public_routes") +@patch("daq_queuing_service.app.app.protected_routes") +def test_create_app_adds_routers( + mock_public_routes: MagicMock, mock_protected_routes: MagicMock +): + create_app(Path(TEST_CONFIG_PATH)) - mock_create_api_router.assert_called_once() + mock_public_routes.assert_called_once() + mock_protected_routes.assert_called_once() def test_lifespan_runs_without_error(): @@ -68,11 +62,12 @@ def test_worker_task_cancelled_on_shutdown(): assert worker_task.cancelled() -def test_queue_and_worker_added_to_app_state_and_queue_object_shared_across_app(): - with patch( - "daq_queuing_service.app.app.create_api_router" - ) as mock_create_api_router: - app = create_app(Path(TEST_CONFIG_PATH)) +@patch("daq_queuing_service.app.app.protected_routes") +def test_queue_and_worker_added_to_app_state_and_queue_object_shared_across_app( + mock_protected_routes: MagicMock, +): + + app = create_app(Path(TEST_CONFIG_PATH)) app_queue = app.state.queue app_worker = app.state.worker @@ -80,7 +75,7 @@ def test_queue_and_worker_added_to_app_state_and_queue_object_shared_across_app( assert isinstance(app_queue, TaskQueue) assert isinstance(app_worker, QueueWorker) assert app_worker._queue is app_queue - assert mock_create_api_router.call_args_list[0].args[0] is app_queue + assert mock_protected_routes.call_args_list[0].args[0] is app_queue @patch( @@ -110,3 +105,20 @@ def test_if_dev_mode_cors_middlewhere_added_to_app(): allow_methods=["*"], allow_headers=["*"], ) + + +def test_create_app_adds_auth_dependencies_to_correct_routes(app_with_auth: FastAPI): + no_auth_required = ["read_root", "healthz", "get_queue_state"] + + for route in app_with_auth.routes: + if isinstance(route, APIRoute): + if route.name not in no_auth_required: + assert has_dependency_name(route.dependant, "validate_bearer_token"), ( + f"No access token check dependency for route {str(route)}" + ) + assert has_dependency_name(route.dependant, "get_current_user"), ( + f"No get user dependency for route {str(route)}" + ) + else: + assert not has_dependency_name(route.dependant, "validate_bearer_token") + assert not has_dependency_name(route.dependant, "get_current_user") diff --git a/tests/unit_tests/test_authentication.py b/tests/unit_tests/test_authentication.py new file mode 100644 index 0000000..9091032 --- /dev/null +++ b/tests/unit_tests/test_authentication.py @@ -0,0 +1,118 @@ +from unittest.mock import MagicMock, patch + +import pytest +from blueapi.config import OIDCConfig +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from jwt import DecodeError, ExpiredSignatureError + +from daq_queuing_service.app.authentication import ( + User, + build_access_token_check, + build_get_current_user, + unchecked_bearer_token, +) + + +@pytest.fixture(autouse=True) +def jwk_client(): + with patch( + "daq_queuing_service.app.authentication.jwt.PyJWKClient" + ) as mock_client_class: + yield mock_client_class.return_value + + +def test_unchecked_bearer_token_returns_credentials(): + expected = "fake credentials" + result = unchecked_bearer_token( + HTTPAuthorizationCredentials(scheme="", credentials=expected) + ) + assert result == expected + + +def test_validate_bearer_token_gets_signing_key_from_jwt_and_decodes_token_with_it( + oidc_config: OIDCConfig, jwk_client: MagicMock +): + token = "fake_token" + validate_bearer_token = build_access_token_check(oidc_config) + with patch("daq_queuing_service.app.authentication.jwt.decode") as mock_decode: + validate_bearer_token(token) + + jwk_client.get_signing_key_from_jwt.assert_called_once_with(token) + signing_key = jwk_client.get_signing_key_from_jwt.return_value.key + mock_decode.assert_called_once_with( + token, + signing_key, + algorithms=oidc_config.id_token_signing_alg_values_supported, + verify=True, + audience=oidc_config.client_audience, + issuer=oidc_config.issuer, + ) + + +def test_validate_bearer_token_raises_appropriate_http_exception_if_no_token_provided( + oidc_config: OIDCConfig, +): + validate_bearer_token = build_access_token_check(oidc_config) + with pytest.raises(HTTPException) as exc: + validate_bearer_token(None) + + assert exc.value.status_code == 401 + assert exc.value.detail == "Not authenticated" + + +def test_validate_bearer_token_raises_appropriate_http_exception_if_decode_error( + oidc_config: OIDCConfig, jwk_client: MagicMock +): + jwk_client.get_signing_key_from_jwt.side_effect = DecodeError + validate_bearer_token = build_access_token_check(oidc_config) + with pytest.raises(HTTPException) as exc: + validate_bearer_token("token") + + assert exc.value.status_code == 401 + assert exc.value.detail == "Cannot decode token" + + +def test_validate_bearer_token_raises_appropriate_http_exception_if_expired_error( + oidc_config: OIDCConfig, jwk_client: MagicMock +): + jwk_client.get_signing_key_from_jwt.side_effect = ExpiredSignatureError + validate_bearer_token = build_access_token_check(oidc_config) + with pytest.raises(HTTPException) as exc: + validate_bearer_token("token") + + assert exc.value.status_code == 401 + assert exc.value.detail == "Token expired" + + +def test_get_current_user_builds_user_from_validated_token_and_adds_to_request_state(): + def validate_user(): + return { + "fedid": "abc12355", + "email": "joe.blogs@diamond.ac.uk", + "name": "Joe Blogs", + } + + expected_user = User( + fedid="abc12355", email="joe.blogs@diamond.ac.uk", name="Joe Blogs" + ) + + get_current_user = build_get_current_user(MagicMock()) + request = MagicMock() + user = get_current_user(request, validate_user()) + + assert user == expected_user + assert request.state.user == expected_user + + +def test_get_current_user_raises_error_if_token_cannot_validate_to_user_class(): + def validate_user(): + return {"no fedid": "abc12355"} + + get_current_user = build_get_current_user(MagicMock()) + request = MagicMock() + with pytest.raises(HTTPException) as exc: + get_current_user(request, validate_user()) + + assert exc.value.status_code == 401 + assert exc.value.detail == "Invalid token claims" diff --git a/tests/unit_tests/test_authorisation.py b/tests/unit_tests/test_authorisation.py new file mode 100644 index 0000000..a26f349 --- /dev/null +++ b/tests/unit_tests/test_authorisation.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from daq_queuing_service.app.authentication import User +from daq_queuing_service.app.authorisation import ( + build_ensure_current_user_is_in_whitelist, +) + + +def test_ensure_current_user_is_in_whitelist_returns_user_if_user_in_whitelist(): + user = User(fedid="abc12345") + + whitelist_check = build_ensure_current_user_is_in_whitelist( + ["abc12345", "def67890"], MagicMock() + ) + assert whitelist_check(user) == user + + +def test_ensure_current_user_is_in_whitelist_raises_error_if_user_not_in_whitelist(): + user = User(fedid="abc12345") + + whitelist_check = build_ensure_current_user_is_in_whitelist( + ["def67890"], MagicMock() + ) + with pytest.raises(HTTPException): + whitelist_check(user) + + +def test_ensure_current_user_is_in_whitelist_returns_user_if_no_whitelist_provided(): + user = User(fedid="abc12345") + + whitelist_check = build_ensure_current_user_is_in_whitelist(None, MagicMock()) + assert whitelist_check(user) == user diff --git a/tests/unit_tests/test_get_blueapi_client.py b/tests/unit_tests/test_get_blueapi_client.py index 0220a62..9605cff 100644 --- a/tests/unit_tests/test_get_blueapi_client.py +++ b/tests/unit_tests/test_get_blueapi_client.py @@ -1,8 +1,9 @@ from unittest.mock import MagicMock, patch -from blueapi.config import ApplicationConfig, RestConfig, StompConfig +from blueapi.config import RestConfig, StompConfig from pydantic import HttpUrl +from daq_queuing_service.app._config import BlueapiConfig from daq_queuing_service.blueapi_interaction.get_client import get_blueapi_client @@ -15,7 +16,7 @@ def test_get_blueapi_clients_constructs_clients_with_expected_args_and_returns_c mock_token_retriever: MagicMock, ): rest_config = RestConfig(url=HttpUrl("http://test_url.com")) - blueapi_client = get_blueapi_client(ApplicationConfig(api=rest_config)) + blueapi_client = get_blueapi_client(BlueapiConfig(api=rest_config)) mock_rest_client.assert_called_once_with( config=rest_config, session_manager=mock_token_retriever.return_value @@ -35,7 +36,7 @@ def test_get_blueapi_clients_constructs_blueapi_client_with_stomp_if_enabled_in_ ): rest_config = RestConfig(url=HttpUrl("http://test_url.com")) _ = get_blueapi_client( - ApplicationConfig(api=rest_config, stomp=StompConfig(enabled=True)) + BlueapiConfig(api=rest_config, stomp=StompConfig(enabled=True)) ) mock_blueapi_client.assert_called_once_with( diff --git a/tests/unit_tests/test_queue.py b/tests/unit_tests/test_queue.py index 91af02e..ffaf2b2 100644 --- a/tests/unit_tests/test_queue.py +++ b/tests/unit_tests/test_queue.py @@ -305,6 +305,7 @@ async def test_get_queue_only_returns_tasks_in_queue( ], position=0, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -331,6 +332,7 @@ async def test_get_queue_only_returns_tasks_in_queue( ], position=1, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -357,6 +359,7 @@ async def test_get_queue_only_returns_tasks_in_queue( ], position=2, kind=TaskKind.EXPERIMENT, + user=None, ), ] @@ -398,6 +401,7 @@ async def test_get_history_only_returns_tasks_in_history( ], position=None, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -424,6 +428,7 @@ async def test_get_history_only_returns_tasks_in_history( ], position=None, kind=TaskKind.EXPERIMENT, + user=None, ), ] @@ -466,6 +471,7 @@ async def test_get_tasks_returns_tasks_in_queue_and_history( ], position=None, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -492,6 +498,7 @@ async def test_get_tasks_returns_tasks_in_queue_and_history( ], position=None, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -518,6 +525,7 @@ async def test_get_tasks_returns_tasks_in_queue_and_history( ], position=0, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -544,6 +552,7 @@ async def test_get_tasks_returns_tasks_in_queue_and_history( ], position=1, kind=TaskKind.EXPERIMENT, + user=None, ), TaskWithPosition( experiment=Experiment( @@ -570,6 +579,7 @@ async def test_get_tasks_returns_tasks_in_queue_and_history( ], position=2, kind=TaskKind.EXPERIMENT, + user=None, ), ] diff --git a/utility_scripts/generate_api_docs.py b/utility_scripts/generate_api_docs.py index 7f06017..cc25505 100644 --- a/utility_scripts/generate_api_docs.py +++ b/utility_scripts/generate_api_docs.py @@ -4,12 +4,11 @@ from fastapi import FastAPI from fastapi.openapi.utils import get_openapi -from daq_queuing_service.api.api import create_api_router +from daq_queuing_service.api.api import protected_routes, public_routes app = FastAPI() -app.include_router( - create_api_router(MagicMock(), MagicMock(), MagicMock(), MagicMock()) -) +app.include_router(public_routes(MagicMock())) +app.include_router(protected_routes(MagicMock(), MagicMock(), MagicMock(), MagicMock())) openapi = get_openapi( title=app.title, diff --git a/uv.lock b/uv.lock index 8ded321..1371684 100644 --- a/uv.lock +++ b/uv.lock @@ -354,7 +354,7 @@ wheels = [ [[package]] name = "blueapi" -version = "1.18.0" +version = "1.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aioca" }, @@ -381,9 +381,9 @@ dependencies = [ { name = "tomlkit" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/826f1f3818cf6c28b2ef0cc50769245ff32959e21f9b2323613f0c2d8f5f/blueapi-1.18.0.tar.gz", hash = "sha256:1ff7933b9ee2a2620c7817c6435223493a41317fe203277d1f3368063cca01fa", size = 1881191, upload-time = "2026-08-07T09:48:18.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/a6/26003d820f710e7b68e4b2f49f7d5a7700f5f00266f76fb17d2c8baebab4/blueapi-1.18.1.tar.gz", hash = "sha256:99bebac382f3e8a088d9fe22bc3972547e60c6648c36535fd025c83df8e97f37", size = 1881821, upload-time = "2026-08-18T09:36:05.463Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/77/20f7d841722cf820dd863f2a8ec4837bece0e4fcc07804be2f1ee978f308/blueapi-1.18.0-py3-none-any.whl", hash = "sha256:5037f6f27427945577e7a2ba64e10e844b244e1712313d87985b54bc9dba8b43", size = 87982, upload-time = "2026-08-07T09:48:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f6/fa26155e2373a9ef3d76b6a678bab9aad88e49524a10e3b1a2c0409c1758/blueapi-1.18.1-py3-none-any.whl", hash = "sha256:f8c362eda55fcc3eea5c306425456b805c63b14efe680e9790c23ca6080d9e4b", size = 88065, upload-time = "2026-08-18T09:36:04.292Z" }, ] [[package]] @@ -955,7 +955,7 @@ dependencies = [ { name = "blueapi" }, { name = "fastapi" }, { name = "pydantic" }, - { name = "tiled" }, + { name = "tiled", extra = ["client"] }, ] [package.dev-dependencies] @@ -981,10 +981,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "blueapi", specifier = ">=1.18.0" }, + { name = "blueapi", specifier = ">=1.18.1" }, { name = "fastapi", specifier = ">=0.136.0" }, { name = "pydantic", specifier = ">=2.13.2" }, - { name = "tiled", specifier = ">=0.2.9" }, + { name = "tiled", extras = ["client"], specifier = ">=0.2.9" }, ] [package.metadata.requires-dev]