Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Charts/tiled/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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__"
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # allow creates with access_blob: null without asking 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
Expand Down
167 changes: 167 additions & 0 deletions src/dls_tiled/policy.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading