Skip to content
Closed
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
7 changes: 6 additions & 1 deletion helm/blueapi/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,15 @@
"tiled_service_account_check": {
"title": "Tiled Service Account Check",
"type": "string"
},
"submit_task_check": {
"title": "Submit Task Check",
"type": "string"
}
},
"required": [
"tiled_service_account_check"
"tiled_service_account_check",
"submit_task_check"
],
"title": "OpaConfig",
"type": "object",
Expand Down
7 changes: 6 additions & 1 deletion helm/blueapi/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,8 @@
"title": "OpaConfig",
"type": "object",
"required": [
"tiled_service_account_check"
"tiled_service_account_check",
"submit_task_check"
],
"properties": {
"root": {
Expand All @@ -767,6 +768,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
1 change: 1 addition & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ class Tag(StrEnum):
class OpaConfig(BlueapiBaseModel):
root: HttpUrl = HttpUrl("http://localhost:8181")
tiled_service_account_check: str
submit_task_check: str


class ApplicationConfig(BlueapiBaseModel):
Expand Down
38 changes: 36 additions & 2 deletions src/blueapi/service/authorization.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import logging
import re
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager, aclosing, nullcontext
from typing import Any, Self
from typing import Any, Self, cast

import aiohttp
from aiohttp import ClientSession
from fastapi import Depends, HTTPException, Request
from starlette import status

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

LOGGER = logging.getLogger(__name__)
INSTRUMENT_SESSION_RE = re.compile(r"^[a-z]{2}(?P<proposal>\d+)-(?P<visit>\d+)$")


class OpaClient:
Expand Down Expand Up @@ -62,6 +67,20 @@ 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._conf.submit_task_check,
{
"token": token,
"proposal": int(match["proposal"]),
"visit": int(match["visit"]),
},
):
raise HTTPException(status_code=status.HTTP_403_UNORTHORIZED)


class OpaUserClient:
client: OpaClient
Expand All @@ -71,6 +90,10 @@ 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 validate_tiled_config(
tiled: ServiceAccount | str | None, oidc: OIDCConfig | None, opa: OpaClient | None
Expand All @@ -87,3 +110,14 @@ 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=status.HTTP_401_UNAUTHORIZED)
return OpaUserClient(opa, token)
return None
28 changes: 28 additions & 0 deletions tests/unit_tests/service/test_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
from unittest.mock import AsyncMock, MagicMock, Mock, patch

import pytest
from fastapi import HTTPException
from pydantic import HttpUrl

from blueapi.config import OIDCConfig, OpaConfig, ServiceAccount
from blueapi.service.authorization import (
OpaClient,
opa,
validate_tiled_config,
)

Expand All @@ -22,6 +24,7 @@
def opa_config() -> OpaConfig:
return OpaConfig(
root=HttpUrl("http://auth.example.com"),
submit_task_check="/auth/submit",
tiled_service_account_check="/auth/tiled",
)

Expand Down Expand Up @@ -149,3 +152,28 @@ async def test_validate_tiled_config_with_missing_config(
assert await validate_tiled_config(tiled_auth, oidc, opa_client) is None
if opa_client is not None:
opa_client.require_tiled_service_account.assert_not_called()


async def test_opa_dependency_method():
request = MagicMock()

user_client = await opa(request, "foo_bar")

assert user_client is not None
assert user_client.client == request.app.state.authz
assert user_client.token == "foo_bar"


async def test_opa_dependency_without_token():
request = MagicMock()

with pytest.raises(HTTPException, match="401"):
await opa(request, None)


@pytest.mark.parametrize("token", ["foo_bar", None])
async def test_opa_dependency_without_authz(token):
request = MagicMock()
del request.app.state.authz
user_client = await opa(request, token)
assert user_client is None
1 change: 1 addition & 0 deletions tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ def test_config_yaml_parsed(temp_yaml_config_file):
"opa": {
"root": "http://opa.example.com/",
"tiled_service_account_check": "v1/tiled_service_account",
"submit_task_check": "v1/submit_task",
},
},
{
Expand Down