From 274c4bd70696b1760225f6eb4d8d61ef0137382f Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Tue, 13 Oct 2026 10:41:59 +0000 Subject: [PATCH 1/2] Add the DLS OPA access policy from diamond-tiled Port DiamondOpenPolicyAgentAuthorizationPolicy from diamond-tiled's diamond_policy/dls.py into the package as dls_tiled.policy, so the image can be configured with access_control: access_policy: dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy The policy logic is unchanged; it already builds against tiled 0.2.18. Add unit tests against a mocked OPA, import the module in the image smoke check, and document it in the README and the chart values. Co-Authored-By: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CCtPFejfd3WP589miPqaL3 --- Charts/tiled/values.yaml | 6 + Dockerfile | 2 +- README.md | 38 +++++ src/dls_tiled/policy.py | 167 +++++++++++++++++++ tests/test_policy.py | 341 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/dls_tiled/policy.py create mode 100644 tests/test_policy.py diff --git a/Charts/tiled/values.yaml b/Charts/tiled/values.yaml index 7a89b08..79be691 100644 --- a/Charts/tiled/values.yaml +++ b/Charts/tiled/values.yaml @@ -8,6 +8,12 @@ config: # endpoint: # routers: # - dls_tiled.visr:visr_router + # Opt in to the DLS access policy (OPA-backed; see the README for every argument): + # access_control: + # access_policy: dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy + # args: + # authorization_provider: http://opa:8181/v1/data/ + # token_audience: account authentication: allow_anonymous_access: false trees: diff --git a/Dockerfile b/Dockerfile index 58f4d58..ef53c51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,5 +33,5 @@ USER app COPY log_config.yml /deploy/log_config.yml # Check the plugins import and that upstream tiled was left at its release -RUN python -c "import dls_tiled.visr, tiled; \ +RUN python -c "import dls_tiled.visr, dls_tiled.policy, tiled; \ assert tiled.__version__ == '${TILED_VERSION}', tiled.__version__" diff --git a/README.md b/README.md index 828956e..b23ca44 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,44 @@ routers: Ported from Hiran Wijesinghe's [visr-tiled](https://github.com/hyperrealist/visr-tiled). +## DLS access policy + +`dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy` is a tiled access +policy that delegates every decision to an [Open Policy Agent](https://www.openpolicyagent.org/) +server. Each request sends OPA the user's access token and the token audience, +plus the node's proposal, visit and beamline (from a `{"proposal", "visit", +"beamline"}` JSON tag in the node's `access_blob`) or its session number (from +an integer tag). Reading, creating and modifying nodes need a user principal; +anonymous requests and service principals get a 401. + +- reading: OPA returns the tags the user may see (`allowed_tags_endpoint`); + `["*"]` means everything. +- creating a node: OPA returns the tag to store in the new node's `access_blob` + (`create_node_endpoint`). +- changing a node's `access_blob`: OPA returns a yes/no (`modify_node_endpoint`). +- scopes on a node: OPA returns the allowed scopes (`scopes_endpoint`). + +Every argument, with the defaults for the optional ones: + +```yaml +access_control: + access_policy: dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy + args: + authorization_provider: http://opa:8181/v1/data/ # OPA data API base URL + token_audience: account # sent to OPA as "audience" + create_node_endpoint: tiled/user_session + allowed_tags_endpoint: tiled/user_sessions + scopes_endpoint: tiled/scopes + modify_node_endpoint: tiled/modify_session + empty_access_blob_public: true # create nodes with no access_blob without OPA + provider: null +``` + +The endpoints are appended to `authorization_provider` as they are, so it needs +a trailing `/`. + +Ported from Zoheb Shaikh's [diamond-tiled](https://github.com/DiamondLightSource/diamond-tiled). + ## Helm chart `Charts/tiled` is a generic Helm chart for the image, published on each tag to diff --git a/src/dls_tiled/policy.py b/src/dls_tiled/policy.py new file mode 100644 index 0000000..b27fc3e --- /dev/null +++ b/src/dls_tiled/policy.py @@ -0,0 +1,167 @@ +"""Diamond Light Source tiled access policy, backed by Open Policy Agent. + +Ported from Zoheb Shaikh's diamond-tiled +(https://github.com/DiamondLightSource/diamond-tiled). +""" + +import json +import logging + +from fastapi import HTTPException +from pydantic import BaseModel, HttpUrl, TypeAdapter +from starlette.status import ( + HTTP_401_UNAUTHORIZED, +) +from tiled.access_control.access_policies import ( + ALL_ACCESS, + NO_ACCESS, + ExternalPolicyDecisionPoint, + ResultHolder, +) +from tiled.adapters.protocols import BaseAdapter +from tiled.queries import AccessBlobFilter +from tiled.server.schemas import Principal, PrincipalType +from tiled.type_aliases import AccessBlob, AccessTags, Filters, Scopes + +logger = logging.getLogger(__name__) + + +class DiamondAccessBlob(BaseModel): + proposal: int + visit: int + beamline: str + + +def _check_principal(principal: Principal | None): + if not isinstance(principal, Principal): + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Principal is None", + headers={"WWW-Authenticate": "Bearer"}, + ) + if principal.type != PrincipalType.user: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail=f"Principal of type {PrincipalType.user}" + f" required but given {principal.type}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class DiamondOpenPolicyAgentAuthorizationPolicy(ExternalPolicyDecisionPoint): + def __init__( + self, + authorization_provider: HttpUrl, + token_audience: str, + create_node_endpoint: str = "tiled/user_session", + allowed_tags_endpoint: str = "tiled/user_sessions", + scopes_endpoint: str = "tiled/scopes", + modify_node_endpoint: str = "tiled/modify_session", + empty_access_blob_public: bool = True, + provider: str | None = None, + ): + self._token_audience = token_audience + self._type_adapter = TypeAdapter(DiamondAccessBlob | int) + + super().__init__( + authorization_provider=authorization_provider, + create_node_endpoint=create_node_endpoint, + allowed_tags_endpoint=allowed_tags_endpoint, + scopes_endpoint=scopes_endpoint, + provider=provider, + modify_node_endpoint=modify_node_endpoint, + empty_access_blob_public=empty_access_blob_public, + ) + + async def init_node( + self, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + access_blob: AccessBlob | None = None, + ) -> tuple[bool, AccessBlob | None]: + _check_principal(principal) + if access_blob is None and self._empty_access_blob_public is not None: + return self._empty_access_blob_public, access_blob + decision = await self._get_external_decision( + self._create_node, + self.build_input(principal, authn_access_tags, authn_scopes, access_blob), + ResultHolder[str], + ) + if decision and decision.result is not None: + return (True, {"tags": [decision.result]}) + raise ValueError("Permission denied not able to add the node") + + async def modify_node( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + access_blob: AccessBlob | None, + ) -> tuple[bool, AccessBlob | None]: + _check_principal(principal) + if access_blob == node.access_blob: # type: ignore + logger.info( + "Node access_blob not modified;" + f" access_blob is identical: {access_blob}" + ) + return (False, node.access_blob) # type: ignore + decision = await self._get_external_decision( + self._modify_node, + self.build_input(principal, authn_access_tags, authn_scopes, access_blob), + ResultHolder[bool], + ) + if decision: + return (decision.result, access_blob) + raise ValueError("Permission denied not able to add the node") + + def build_input( + self, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + access_blob: AccessBlob | None = None, + ) -> str: + _input: dict[str, str | int] = {"audience": self._token_audience} + + if ( + isinstance(principal, Principal) + and principal.type is PrincipalType.user + and principal.access_token is not None + ): + _input["token"] = principal.access_token.get_secret_value() + + if ( + access_blob is not None + and "tags" in access_blob + and len(access_blob["tags"]) > 0 + ): + blob = self._type_adapter.validate_json(access_blob["tags"][0]) + if isinstance(blob, DiamondAccessBlob): + _input.update(blob.model_dump()) + elif isinstance(blob, int): + _input["session"] = str(blob) + + return json.dumps({"input": _input}) + + async def filters( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + scopes: Scopes, + ) -> Filters: + _check_principal(principal) + tags = await self._get_external_decision( + self._user_tags, + self.build_input(principal, authn_access_tags, authn_scopes), + ResultHolder[list[str]], + ) + if tags is not None: + if tags.result == ["*"]: + return ALL_ACCESS + return [AccessBlobFilter(tags=tags.result, user_id=None)] # type: ignore + else: + return NO_ACCESS # type: ignore diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..d50ff90 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,341 @@ +import json +import uuid +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +from fastapi import HTTPException +from pydantic import HttpUrl, SecretStr, ValidationError +from tiled.access_control import access_policies +from tiled.access_control.access_policies import ALL_ACCESS, NO_ACCESS +from tiled.config import parse_configs +from tiled.queries import AccessBlobFilter +from tiled.server.schemas import Principal, PrincipalType + +from dls_tiled.policy import ( + DiamondOpenPolicyAgentAuthorizationPolicy, + _check_principal, +) + +OPA = "http://opa.example:8181/v1/data/" +AUDIENCE = "tiled-audience" +BLOB_TAG = '{"proposal": 1234, "visit": 5, "beamline": "i22"}' +BLOB_FIELDS = {"proposal": 1234, "visit": 5, "beamline": "i22"} + +# async tests run on anyio's pytest plugin, which ships with tiled's httpx +pytestmark = pytest.mark.anyio + + +def make_principal( + type: PrincipalType = PrincipalType.user, token: str | None = "the-token" +) -> Principal: + return Principal( + uuid=uuid.uuid4(), + type=type, + access_token=SecretStr(token) if token is not None else None, + ) + + +@pytest.fixture +def policy() -> DiamondOpenPolicyAgentAuthorizationPolicy: + return DiamondOpenPolicyAgentAuthorizationPolicy( + authorization_provider=HttpUrl(OPA), token_audience=AUDIENCE + ) + + +@pytest.fixture +def user() -> Principal: + return make_principal() + + +class FakeOPA: + """Records POSTs to the policy decision point and answers from a URL map.""" + + def __init__(self, responses: dict[str, Any]): + self.responses = responses + self.requests: list[tuple[str, dict]] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + url = str(request.url) + self.requests.append((url, json.loads(request.content))) + return httpx.Response(200, json=self.responses[url]) + + +@pytest.fixture +def opa(monkeypatch) -> FakeOPA: + fake = FakeOPA({}) + real_client = httpx.AsyncClient + + def client(*args, **kwargs): + return real_client(*args, transport=httpx.MockTransport(fake.handler), **kwargs) + + monkeypatch.setattr(access_policies.httpx, "AsyncClient", client) + return fake + + +# --- build_input: the OPA input document --------------------------------------- + + +def test_build_input_without_access_blob(policy, user): + body = json.loads(policy.build_input(user, None, set())) + + assert body == {"input": {"audience": AUDIENCE, "token": "the-token"}} + + +def test_build_input_with_diamond_access_blob(policy, user): + body = json.loads(policy.build_input(user, None, set(), {"tags": [BLOB_TAG]})) + + assert body["input"] == {"audience": AUDIENCE, "token": "the-token", **BLOB_FIELDS} + + +def test_build_input_with_session_int(policy, user): + body = json.loads(policy.build_input(user, None, set(), {"tags": ["98765"]})) + + assert body["input"] == { + "audience": AUDIENCE, + "token": "the-token", + "session": "98765", + } + + +def test_build_input_uses_first_tag_only(policy, user): + blob = {"tags": ["98765", BLOB_TAG]} + + body = json.loads(policy.build_input(user, None, set(), blob)) + + assert body["input"]["session"] == "98765" + assert "proposal" not in body["input"] + + +@pytest.mark.parametrize("blob", [{}, {"tags": []}, {"user": "someone"}]) +def test_build_input_ignores_blob_without_tags(policy, user, blob): + body = json.loads(policy.build_input(user, None, set(), blob)) + + assert body["input"] == {"audience": AUDIENCE, "token": "the-token"} + + +def test_build_input_rejects_unparseable_tag(policy, user): + with pytest.raises(ValidationError): + policy.build_input(user, None, set(), {"tags": ['{"proposal": 1}']}) + + +def test_build_input_omits_token_when_absent(policy): + body = json.loads(policy.build_input(make_principal(token=None), None, set())) + + assert body["input"] == {"audience": AUDIENCE} + + +def test_build_input_omits_token_for_service_principal(policy): + service = make_principal(type=PrincipalType.service) + + body = json.loads(policy.build_input(service, None, set())) + + assert body["input"] == {"audience": AUDIENCE} + + +# --- principal checks ---------------------------------------------------------- + + +def test_check_principal_none_is_401(): + with pytest.raises(HTTPException) as exc: + _check_principal(None) + + assert exc.value.status_code == 401 + assert exc.value.headers == {"WWW-Authenticate": "Bearer"} + + +def test_check_principal_service_is_401(): + with pytest.raises(HTTPException) as exc: + _check_principal(make_principal(type=PrincipalType.service)) + + assert exc.value.status_code == 401 + assert "service" in exc.value.detail + + +def test_check_principal_user_passes(user): + _check_principal(user) + + +async def test_filters_rejects_service_principal(policy, opa): + service = make_principal(type=PrincipalType.service) + + with pytest.raises(HTTPException) as exc: + await policy.filters(None, service, None, set(), {"read:metadata"}) + + assert exc.value.status_code == 401 + assert opa.requests == [] + + +async def test_init_node_rejects_missing_principal(policy, opa): + with pytest.raises(HTTPException) as exc: + await policy.init_node(None, None, set(), {"tags": [BLOB_TAG]}) # type: ignore + + assert exc.value.status_code == 401 + assert opa.requests == [] + + +# --- filters ------------------------------------------------------------------- + + +async def test_filters_with_tags(policy, user, opa): + opa.responses[OPA + "tiled/user_sessions"] = {"result": ["1", "2"]} + + filters = await policy.filters(None, user, None, set(), {"read:metadata"}) + + assert filters == [AccessBlobFilter(tags=["1", "2"], user_id=None)] + assert opa.requests == [ + ( + OPA + "tiled/user_sessions", + {"input": {"audience": AUDIENCE, "token": "the-token"}}, + ) + ] + + +async def test_filters_wildcard_is_all_access(policy, user, opa): + opa.responses[OPA + "tiled/user_sessions"] = {"result": ["*"]} + + assert await policy.filters(None, user, None, set(), set()) is ALL_ACCESS + + +async def test_filters_without_result_is_no_access(policy, user, opa): + opa.responses[OPA + "tiled/user_sessions"] = {} + + assert await policy.filters(None, user, None, set(), set()) is NO_ACCESS + + +# --- init_node ----------------------------------------------------------------- + + +async def test_init_node_empty_blob_public_skips_opa(policy, user, opa): + assert await policy.init_node(user, None, set(), None) == (True, None) + assert opa.requests == [] + + +async def test_init_node_empty_blob_not_public(user, opa): + policy = DiamondOpenPolicyAgentAuthorizationPolicy( + authorization_provider=HttpUrl(OPA), + token_audience=AUDIENCE, + empty_access_blob_public=False, + ) + + assert await policy.init_node(user, None, set(), None) == (False, None) + assert opa.requests == [] + + +async def test_init_node_returns_tag_from_opa(policy, user, opa): + opa.responses[OPA + "tiled/user_session"] = {"result": "12345"} + + result = await policy.init_node(user, None, set(), {"tags": [BLOB_TAG]}) + + assert result == (True, {"tags": ["12345"]}) + assert opa.requests == [ + ( + OPA + "tiled/user_session", + {"input": {"audience": AUDIENCE, "token": "the-token", **BLOB_FIELDS}}, + ) + ] + + +async def test_init_node_denied(policy, user, opa): + opa.responses[OPA + "tiled/user_session"] = {} + + with pytest.raises(ValueError, match="Permission denied"): + await policy.init_node(user, None, set(), {"tags": [BLOB_TAG]}) + + +# --- modify_node --------------------------------------------------------------- + + +async def test_modify_node_identical_blob_skips_opa(policy, user, opa): + node = SimpleNamespace(access_blob={"tags": ["1"]}) + + result = await policy.modify_node(node, user, None, set(), {"tags": ["1"]}) + + assert result == (False, {"tags": ["1"]}) + assert opa.requests == [] + + +@pytest.mark.parametrize("allowed", [True, False]) +async def test_modify_node_asks_opa(policy, user, opa, allowed): + opa.responses[OPA + "tiled/modify_session"] = {"result": allowed} + node = SimpleNamespace(access_blob={"tags": ["1"]}) + + result = await policy.modify_node(node, user, None, set(), {"tags": ["2"]}) + + assert result == (allowed, {"tags": ["2"]}) + assert opa.requests == [ + ( + OPA + "tiled/modify_session", + {"input": {"audience": AUDIENCE, "token": "the-token", "session": "2"}}, + ) + ] + + +async def test_modify_node_denied(policy, user, opa): + opa.responses[OPA + "tiled/modify_session"] = {"result": "not-a-bool"} + node = SimpleNamespace(access_blob={"tags": ["1"]}) + + with pytest.raises(ValueError, match="Permission denied"): + await policy.modify_node(node, user, None, set(), {"tags": ["2"]}) + + +# --- allowed_scopes (inherited from ExternalPolicyDecisionPoint) --------------- + + +async def test_allowed_scopes_sends_node_blob(policy, user, opa): + opa.responses[OPA + "tiled/scopes"] = {"result": ["read:metadata", "read:data"]} + node = SimpleNamespace(access_blob={"tags": [BLOB_TAG]}) + + scopes = await policy.allowed_scopes(node, user, None, set()) + + assert scopes == {"read:metadata", "read:data"} + assert opa.requests == [ + ( + OPA + "tiled/scopes", + {"input": {"audience": AUDIENCE, "token": "the-token", **BLOB_FIELDS}}, + ) + ] + + +async def test_allowed_scopes_without_result_is_empty(policy, user, opa): + opa.responses[OPA + "tiled/scopes"] = {} + + assert await policy.allowed_scopes(None, user, None, set()) == set() + + +# --- loading through a tiled config -------------------------------------------- + + +def test_loads_from_tiled_config(tmp_path): + config_file = tmp_path / "config.yml" + config_file.write_text( + f""" +access_control: + access_policy: dls_tiled.policy:DiamondOpenPolicyAgentAuthorizationPolicy + args: + authorization_provider: {OPA} + token_audience: {AUDIENCE} + create_node_endpoint: tiled/create + allowed_tags_endpoint: tiled/tags + scopes_endpoint: tiled/node_scopes + modify_node_endpoint: tiled/modify + empty_access_blob_public: false + provider: diamond +trees: + - path: / + tree: tiled.examples.generated_minimal:tree +""" + ) + + config = parse_configs(config_file) + + policy = config.access_policy + assert isinstance(policy, DiamondOpenPolicyAgentAuthorizationPolicy) + assert policy._token_audience == AUDIENCE + assert policy._create_node == OPA + "tiled/create" + assert policy._user_tags == OPA + "tiled/tags" + assert policy._node_scopes == OPA + "tiled/node_scopes" + assert policy._modify_node == OPA + "tiled/modify" + assert policy._empty_access_blob_public is False + assert policy._provider == "diamond" From b916690df3b65f190c10a514d9207d23c2035d3b Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Tue, 13 Oct 2026 10:46:55 +0000 Subject: [PATCH 2/2] Correct the empty_access_blob_public comment in the README tiled clients send access_blob {} by default, so only an explicit null skips OPA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CCtPFejfd3WP589miPqaL3 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b23ca44..d64e214 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ access_control: allowed_tags_endpoint: tiled/user_sessions scopes_endpoint: tiled/scopes modify_node_endpoint: tiled/modify_session - empty_access_blob_public: true # create nodes with no access_blob without OPA + empty_access_blob_public: true # allow creates with access_blob: null without asking OPA provider: null ```