diff --git a/CHANGELOG.md b/CHANGELOG.md index 436dd6abc..8735d01d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Write the date in place of the "Unreleased" in the case a new version is release - Optional `persist` query parameter to PUT and PATCH /array/... routes, and the corresponding DaskArrayClient methods: `write`, `write_block`, `patch`. - Added new delete:node and delete:revision scopes +- Add ExternalPolicyDecisionPoint for authorization and an example working with Open Policy Agent ### Changed diff --git a/example_configs/keycloak_oidc/Dockerfile b/example_configs/keycloak_oidc/Dockerfile index 8e5628d39..f7eb02717 100644 --- a/example_configs/keycloak_oidc/Dockerfile +++ b/example_configs/keycloak_oidc/Dockerfile @@ -4,5 +4,5 @@ RUN dnf install --installroot /mnt/rootfs curl --releasever 9 --setopt install_w dnf --installroot /mnt/rootfs clean all && \ rpm --root /mnt/rootfs -e --nodeps setup -FROM quay.io/keycloak/keycloak +FROM quay.io/keycloak/keycloak:26.4 COPY --from=ubi-micro-build /mnt/rootfs / diff --git a/example_configs/keycloak_oidc/README.md b/example_configs/keycloak_oidc/README.md index 53fab39e0..5ff9adc50 100644 --- a/example_configs/keycloak_oidc/README.md +++ b/example_configs/keycloak_oidc/README.md @@ -1,14 +1,38 @@ -# Running a Local Keycloak Instance for Authentication +# Running a Local Keycloak Instance for Authentication and OPA for Authorization 1. In this directory, run `docker compose up`. This will start three services defined in the Docker Compose file: - **Keycloak**: Handles authentication. - **oauth2-proxy**: Acts as a proxy to authenticate users. +- **OPA (Open Policy Agent)**: Manages authorization based on defined policies. The current example uses role-based access control, granting permissions according to user roles. 2. Start the Tiled server using the configuration file located at `example_configs/keycloak_oidc/config.yaml`. 3. Open your browser and go to [http://localhost:4180](http://localhost:4180) (served by oauth2-proxy). You will be prompted to log in. Use `admin` for both the username and password. 4. After logging in as `admin`, you will have access to all resources. +The diagram below illustrates how the different services work together to provide authentication and authorization for the Tiled server. + +```mermaid +sequenceDiagram + actor User + participant OAuth2Proxy as OAuth2 Proxy + participant Keycloak + participant Tiled + participant OPA as Open Policy Agent (OPA) + + User->>OAuth2Proxy: Request access to application + OAuth2Proxy->>Keycloak: Redirect user for authentication + activate Keycloak + Keycloak-->>OAuth2Proxy: Return JWT Access Token + deactivate Keycloak + OAuth2Proxy->>Tiled: Forward request with JWT Access Token + Tiled->>OPA: Validate token & request authorization + activate OPA + OPA-->>Tiled: Return authorization decision (allow/deny) + deactivate OPA + Tiled->>User: Provide resources if authentication & authorization succeed +``` + > **Note:** This configuration exposes all secrets and passwords to make it easier to use as an example. **Do not use this setup in production.** diff --git a/example_configs/keycloak_oidc/compose.yaml b/example_configs/keycloak_oidc/compose.yaml index bc1207020..d991d9abc 100644 --- a/example_configs/keycloak_oidc/compose.yaml +++ b/example_configs/keycloak_oidc/compose.yaml @@ -24,7 +24,7 @@ services: oauth2-proxy: network_mode: host - image: "quay.io/oauth2-proxy/oauth2-proxy" + image: "quay.io/oauth2-proxy/oauth2-proxy:v7.13.0-amd64" volumes: - ./oauth2-proxy.cfg:/opt/oauth2-proxy.cfg - ./oauth2-alpha.yaml:/opt/oauth2-alpha.yaml @@ -34,3 +34,17 @@ services: depends_on: keycloak: condition: service_healthy + + opa: + network_mode: host + image: openpolicyagent/opa:1.10.1-istio-3-static + ports: + - 8181:8181 + volumes: + - "./policy:/policy" + environment: + - ISSUER=http://localhost:8080/realms/master + command: ["run","--server","--addr",":8181","-b","/policy"] + depends_on: + keycloak: + condition: service_healthy diff --git a/example_configs/keycloak_oidc/config.yaml b/example_configs/keycloak_oidc/config.yaml index 62afd2e72..878dd537e 100644 --- a/example_configs/keycloak_oidc/config.yaml +++ b/example_configs/keycloak_oidc/config.yaml @@ -7,10 +7,34 @@ authentication: client_id: tiled device_flow_client_id: tiled-cli well_known_uri: "http://localhost:8080/realms/master/.well-known/openid-configuration" + scopes: + - openid + - email + - profile confirmation_message: "You have logged in with Proxied OIDC as {id}." +access_control: + access_policy: "tiled.access_control.access_policies:ExternalPolicyDecisionPoint" + args: + authorization_provider: http://localhost:8181/v1/data/rbac/ + audience: tiled_aud + node_access: "allow" + filter_nodes: "tags" + scopes_access: "scopes" + trees: - # Just some arbitrary example data... - # The point of this example is the authenticaiton above. - - tree: tiled.examples.generated_minimal:tree - path: / + - path: / + tree: catalog + args: + uri: "sqlite:///./catalog.db" + writable_storage: "./data" + # This creates the database if it does not exist. This is convenient, but in + # a horizontally-scaled deployment, this can be a race condition and multiple + # containers may simultaneously attempt to create the database. + # If that is a problem, set this to false, and run: + # + # tiled catalog init URI + # + # separately. + init_if_not_exists: true + top_level_access_blob: {"tags": ["public"]} diff --git a/example_configs/keycloak_oidc/example_data.py b/example_configs/keycloak_oidc/example_data.py new file mode 100644 index 000000000..c805c6431 --- /dev/null +++ b/example_configs/keycloak_oidc/example_data.py @@ -0,0 +1,9 @@ +import numpy + +from tiled.client import from_uri + +client = from_uri("http://localhost:4180") + +client.write_array(access_tags=["public"], array=numpy.ones((10, 10)), key="A") +client.write_array(access_tags=["beamline_x_user"], array=numpy.ones((10, 10)), key="B") +client.write_array(access_tags=["beamline_y_user"], array=numpy.ones((10, 10)), key="C") diff --git a/example_configs/keycloak_oidc/policy/rbac/rbac.rego b/example_configs/keycloak_oidc/policy/rbac/rbac.rego new file mode 100644 index 000000000..bd4b36ae5 --- /dev/null +++ b/example_configs/keycloak_oidc/policy/rbac/rbac.rego @@ -0,0 +1,98 @@ +package rbac + +import data.token + +public_tag := {"public"} + +admin_tag := "facility_admin" +tag_permissions := { + "beamline_y_user": [ + "read:data", + "read:metadata", + ], + admin_tag: [ + "read:data", + "read:metadata", + "write:data", + "write:metadata", + "create", + "register", + "delete:node", + "delete:revision" + ], + "beamline_x_user": [ + "read:data", + "read:metadata", + ], + "public": [ + "read:data", + "write:data", + "read:metadata", + "write:metadata", + "create", + "register", + "delete:node", + "delete:revision" + ], +} + +users := { + "alice": {"tags": ["beamline_x_user"]}, + "bob": {"tags": ["beamline_y_user"]}, + "cara": {"tags": [admin_tag]}, + "admin": {"tags": [admin_tag, "beamline_x_user"]}, +} + +default is_admin := false + +is_admin if { + admin_tag in users[token.name].tags +} + +tags contains tag if { + some tag in users[token.name].tags +} + +tags contains tag if { + some tag in public_tag +} + +tags contains tag if { + is_admin + some tag in object.keys(tag_permissions) +} + +input_tags contains tag if some tag in input.access_blob.tags +allowed_tags := tags & input_tags + +scopes contains p if { + some tag in allowed_tags + some p in tag_permissions[tag] +} + +scopes contains p if { + is_admin + some p in tag_permissions.public +} + +tag_valid if { + every tag in input_tags { + tag in object.keys(tag_permissions) + } +} + +user_tags contains tag if some tag in users[token.name].tags + +extra_tags := input_tags - user_tags + +default allow := false + +allow if { + tag_valid + count(extra_tags) == 0 +} + +allow if { + tag_valid + is_admin +} diff --git a/example_configs/keycloak_oidc/policy/rbac/rbac_test.rego b/example_configs/keycloak_oidc/policy/rbac/rbac_test.rego new file mode 100644 index 000000000..2522a9a9b --- /dev/null +++ b/example_configs/keycloak_oidc/policy/rbac/rbac_test.rego @@ -0,0 +1,109 @@ +package rbac_test + +import data.rbac + +admin_tag := "facility_admin" + +users := { + "alice": {"tags": ["beamline_x_user"]}, + "bob": {"tags": ["facility_user"]}, + "cara": {"tags": [admin_tag]}, + "admin": {"tags": [admin_tag, "beamline_x_user"]}, +} + +test_allowed_to_every_tag_if_admin if { + rbac.allow with input as {"access_blob": {"tags": ["public"]}} + with data.token as {"name": "admin"} + with rbac.users as users +} + +test_not_allowed_to_add_invalid_tags if { + not rbac.allow with input as {"access_blob": {"tags": ["beamline_y_user"]}} + with data.token as {"name": "admin"} + with rbac.users as users +} + +test_user_allowed_to_add_user_tags if { + rbac.allow with input as {"access_blob": {"tags": ["facility_user"]}} + with data.token as {"name": "bob"} + with rbac.users as users +} + +test_user_not_allowed_to_add_invalid_tags if { + not rbac.allow with input as {"access_blob": {"tags": ["beamline_x_user"]}} + with data.token as {"name": "bob"} + with rbac.users as users +} + +test_user_is_admin if { + rbac.is_admin with data.token as {"name": "admin"} + with rbac.users as users +} + +test_user_is_not_admin if { + not rbac.is_admin with data.token as {"name": "alice"} + with rbac.users as users +} + +test_admin_has_all_tags if { + rbac.tags == {"facility_user", "facility_admin", "beamline_x_user", "public"} with data.token as {"name": "admin"} + with rbac.users as users +} + +test_beamline_user_has_only_beamline_tags if { + rbac.tags == {"beamline_x_user", "public"} with data.token as {"name": "alice"} + with rbac.users as users +} + +test_allowed_tags if { + rbac.allowed_tags = {"beamline_x_user"} with input as {"access_blob": {"tags": ["beamline_x_user"]}} + with data.token as {"name": "admin"} + with rbac.users as users +} + +test_allowed_tags_for_public_tag if { + rbac.allowed_tags == {"public"} with input as {"access_blob": {"tags": ["public"]}} + with data.token as {"name": "alice"} + with rbac.users as users +} + +test_allowed_scopes_for_admin if { + rbac.scopes == { + "read:data", + "read:metadata", + "write:data", + "write:metadata", + "create", + "register", + } with input as {"access_blob": {"tags": ["facility_admin"]}} + with data.token as {"name": "admin"} + with rbac.users as users +} + +test_allowed_scopes_for_admin_for_any_resource if { + rbac.scopes == { + "read:data", + "read:metadata", + "write:data", + "write:metadata", + "create", + "register", + } with input as {"access_blob": {"tags": ["beamline_x_user"]}} + with data.token as {"name": "admin"} + with rbac.users as users +} + +test_allowed_scopes_for_unauthorized_user if { + count(rbac.scopes) == 0 with input as {"access_blob": {"tags": ["facility_admin"]}} + with data.token as {"name": "alice"} + with rbac.users as users +} + +test_allowed_scopes_for_user if { + rbac.scopes == { + "read:data", + "read:metadata", + } with input as {"access_blob": {"tags": ["beamline_x_user"]}} + with data.token as {"name": "alice"} + with rbac.users as users +} diff --git a/example_configs/keycloak_oidc/policy/token/token.rego b/example_configs/keycloak_oidc/policy/token/token.rego new file mode 100644 index 000000000..fb87ba79f --- /dev/null +++ b/example_configs/keycloak_oidc/policy/token/token.rego @@ -0,0 +1,39 @@ +package token + +issuer := opa.runtime().env.ISSUER + +jwks_endpoint := jwks_endpoint if { + metadata := http.send({ + "url": concat("", [issuer, "/.well-known/openid-configuration"]), + "method": "GET", + "force_cache": true, + "force_cache_duration_seconds": 86400, + }).body + jwks_endpoint := metadata.jwks_uri +} + +fetch_jwks(url) := http.send({ + "url": url, + "method": "GET", + "force_cache": true, + "force_cache_duration_seconds": 86400, +}) + +unverified := io.jwt.decode(input.token) + +jwt_header := unverified[0] + +jwks_url := concat("?", [jwks_endpoint, urlquery.encode_object({"kid": jwt_header.kid})]) + +jwks := fetch_jwks(jwks_url).raw_body + +verified := io.jwt.decode_verify(input.token, { + "cert": jwks, + "iss": issuer, + "aud": input.audience, +}) + +claims := verified[2] if verified[0] + +roles := claims.realm_access.roles +name := claims.preferred_username diff --git a/example_configs/keycloak_oidc/realm-export.json b/example_configs/keycloak_oidc/realm-export.json index 9136415c1..c6af18fb9 100644 --- a/example_configs/keycloak_oidc/realm-export.json +++ b/example_configs/keycloak_oidc/realm-export.json @@ -856,6 +856,14 @@ "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, "protocolMappers": [ + { + "id": "dccb8ada-64d3-40a7-8c44-002c01df48ed", + "name": "Nonce", + "protocol": "openid-connect", + "protocolMapper": "oidc-nonce-backwards-compatible-mapper", + "consentRequired": false, + "config": {} + }, { "id": "72c90cf1-f71c-434c-bf8b-6135bc5e0f81", "name": "email", @@ -868,6 +876,20 @@ "jsonType.label": "String" } }, + { + "id": "af58bf06-d7d3-4046-80eb-f58d5ae35aca", + "name": "tiled_aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true", + "included.custom.audience": "tiled_aud" + } + }, { "id": "31d9504c-c1e2-4c12-bba5-abea888500e5", "name": "full name", @@ -891,21 +913,6 @@ "jsonType.label": "String" } }, - { - "id": "bf2af61b-7c02-4a8e-9217-e89e113e6541", - "name": "tiled", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "false", - "lightweight.claim": "false", - "introspection.token.claim": "true", - "access.token.claim": "true", - "included.custom.audience": "tiled_aud", - "userinfo.token.claim": "false" - } - }, { "id": "092ef6ba-cd44-4391-a365-3a28e1287f9c", "name": "family name", @@ -966,6 +973,7 @@ "attributes": { "standard.token.exchange.enabled": "false", "frontchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", "oauth2.device.authorization.grant.enabled": "true", "backchannel.logout.revoke.offline.tokens": "false", "use.refresh.tokens": "true", @@ -993,8 +1001,10 @@ "config": { "id.token.claim": "false", "lightweight.claim": "false", + "introspection.token.claim": "true", "access.token.claim": "true", - "introspection.token.claim": "true" + "included.custom.audience": "tiled_aud", + "userinfo.token.claim": "false" } }, { @@ -1048,6 +1058,7 @@ "defaultClientScopes": [ "web-origins", "acr", + "offline_access", "roles", "profile", "basic", @@ -1057,7 +1068,6 @@ "address", "phone", "read:metadata", - "offline_access", "microprofile-jwt" ] } @@ -1808,13 +1818,13 @@ "config": { "allowed-protocol-mapper-types": [ "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", "oidc-address-mapper", - "saml-user-attribute-mapper", "oidc-full-name-mapper", "saml-user-property-mapper", - "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper", - "oidc-usermodel-attribute-mapper" + "saml-role-list-mapper", + "saml-user-attribute-mapper" ] } }, @@ -1827,12 +1837,12 @@ "config": { "allowed-protocol-mapper-types": [ "oidc-sha256-pairwise-sub-mapper", - "oidc-usermodel-attribute-mapper", - "saml-user-attribute-mapper", - "oidc-address-mapper", - "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", "saml-role-list-mapper" ] } @@ -2680,7 +2690,7 @@ "cibaInterval": "5", "realmReusableOtpCode": "false" }, - "keycloakVersion": "26.3.3", + "keycloakVersion": "26.4.5", "userManagedAccessAllowed": false, "organizationsEnabled": false, "verifiableCredentialsEnabled": false, diff --git a/tiled/_tests/test_access_policy.py b/tiled/_tests/test_access_policy.py new file mode 100644 index 000000000..86f85b844 --- /dev/null +++ b/tiled/_tests/test_access_policy.py @@ -0,0 +1,239 @@ +import uuid +from unittest.mock import MagicMock + +import pytest +import respx +from httpx import Response +from pydantic import HttpUrl, SecretStr + +from tiled.access_control.scopes import NO_SCOPES +from tiled.queries import AccessBlobFilter + +from ..access_control.access_policies import ExternalPolicyDecisionPoint +from ..server.schemas import Principal, PrincipalType + + +@pytest.fixture +def external_policy() -> ExternalPolicyDecisionPoint: + return ExternalPolicyDecisionPoint( + authorization_provider=HttpUrl("http://example.com"), + audience="aud", + node_access="allow", + filter_nodes="tags", + scopes_access="scopes", + ) + + +@pytest.fixture +def principal() -> Principal: + return Principal( + type=PrincipalType.external, + identities=[], + uuid=uuid.uuid4(), + access_token=SecretStr("token123"), + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_node_access_allowed( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + respx.post(external_policy._node_access).mock( + return_value=Response(200, json={"result": True}) + ) + assert await external_policy.init_node( + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + access_blob={"tags": {"beamline_x_user"}}, + ) == (True, {"tags": {"beamline_x_user"}}) + + +@pytest.mark.asyncio +@respx.mock +async def test_node_access_denied( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + respx.post(external_policy._node_access).mock( + return_value=Response(200, json={"result": False}) + ) + with pytest.raises(ValueError, match="Permission denied not able to add the node"): + await external_policy.init_node( + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + access_blob={"tags": {"beamline_x_user"}}, + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_node_modify_allowed( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + node = MagicMock() + node.access_blob = None + respx.post(external_policy._node_access).mock( + return_value=Response(200, json={"result": True}) + ) + assert await external_policy.modify_node( + node=node, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + access_blob={"tags": {"beamline_x_user"}}, + ) == (True, {"tags": {"beamline_x_user"}}) + + +@pytest.mark.asyncio +@respx.mock +async def test_node_modify_denied( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + node = MagicMock() + node.access_blob = None + respx.post(external_policy._node_access).mock( + return_value=Response(200, json={"result": False}) + ) + with pytest.raises(ValueError, match="Permission denied not able to add the node"): + await external_policy.modify_node( + node=node, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + access_blob={"tags": {"beamline_x_user"}}, + ) + + +@pytest.mark.asyncio +async def test_node_modify_with_same_not_modified( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + node = MagicMock() + node.access_blob = {"tags": {"beamline_x_user"}} + assert await external_policy.modify_node( + node=node, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + access_blob={"tags": {"beamline_x_user"}}, + ) == (False, {"tags": {"beamline_x_user"}}) + + +@pytest.mark.asyncio +@respx.mock +async def test_access_filters( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + output = {"result": ["beamline_x"]} + respx.post(external_policy._filter_nodes).mock( + return_value=Response(200, json=output) + ) + + filters = await external_policy.filters( + node=MagicMock(), + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + scopes=set([]), + ) + assert filters == [AccessBlobFilter(tags=output["result"], user_id=None)] + + +@pytest.mark.asyncio +@respx.mock +async def test_allowed_scopes( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + output = {"result": ["read:data", "write:data"]} + respx.post(external_policy._scopes_access).mock( + return_value=Response(200, json=output) + ) + + allowed_scopes = await external_policy.allowed_scopes( + node=None, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + ) + assert allowed_scopes == set(output["result"]) + + +@pytest.mark.asyncio +@respx.mock +async def test_allowed_scopes_return_no_scopes_if_invalid_response( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + respx.post(external_policy._scopes_access).mock( + return_value=Response(200, json={"result": True}) + ) + + allowed_scopes = await external_policy.allowed_scopes( + node=None, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + ) + assert allowed_scopes == NO_SCOPES + + +@pytest.mark.asyncio +@respx.mock +async def test_allowed_scopes_return_no_scopes_if_validation_error( + external_policy: ExternalPolicyDecisionPoint, principal: Principal +): + respx.post(external_policy._scopes_access).mock( + return_value=Response(200, json=True) + ) + + allowed_scopes = await external_policy.allowed_scopes( + node=None, + principal=principal, + authn_access_tags=set(), + authn_scopes=set([]), + ) + assert allowed_scopes == NO_SCOPES + + +def test_identifier_method_for_external_principal_with_no_access_token( + external_policy: ExternalPolicyDecisionPoint, +): + principal = Principal( + type=PrincipalType.external, + identities=[], + uuid=uuid.uuid4(), + access_token=None, + ) + + with pytest.raises( + ValueError, match="Access token not provided for external principal type" + ): + external_policy._identifier(principal) + + +def test_identifier_method_for_external_principal( + external_policy: ExternalPolicyDecisionPoint, +): + principal = Principal( + type=PrincipalType.external, + identities=[], + uuid=uuid.uuid4(), + access_token=SecretStr("token123"), + ) + + assert external_policy._identifier(principal) == "token123" + + +def test_identifier_method_for_service_principal( + external_policy: ExternalPolicyDecisionPoint, +): + principal_uuid = uuid.uuid4() + principal = Principal( + type=PrincipalType.service, + identities=[], + uuid=principal_uuid, + access_token=None, + ) + + assert external_policy._identifier(principal) == str(principal_uuid) diff --git a/tiled/access_control/access_policies.py b/tiled/access_control/access_policies.py index 9350a6e70..593188b85 100644 --- a/tiled/access_control/access_policies.py +++ b/tiled/access_control/access_policies.py @@ -1,14 +1,17 @@ import logging import os -from typing import Optional, Tuple +from typing import List, Optional, Tuple, Union + +import httpx +from pydantic import BaseModel, HttpUrl, ValidationError from ..adapters.protocols import BaseAdapter from ..queries import AccessBlobFilter -from ..server.schemas import Principal +from ..server.schemas import Principal, PrincipalType from ..type_aliases import AccessBlob, AccessTags, Filters, Scopes from ..utils import Sentinel, import_object from .protocols import AccessPolicy -from .scopes import ALL_SCOPES, PUBLIC_SCOPES +from .scopes import ALL_SCOPES, NO_SCOPES, PUBLIC_SCOPES ALL_ACCESS = Sentinel("ALL_ACCESS") NO_ACCESS = Sentinel("NO_ACCESS") @@ -19,7 +22,6 @@ handler.setLevel("DEBUG") handler.setFormatter(logging.Formatter("TILED ACCESS POLICY: %(message)s")) logger.addHandler(handler) - log_level = os.getenv("TILED_ACCESS_POLICY_LOG_LEVEL") if log_level: logger.setLevel(log_level.upper()) @@ -412,3 +414,167 @@ async def filters( queries.append(query_filter(identifier, tag_list)) return queries + + +class Data(BaseModel): + token: str + audience: Optional[str] + access_blob: Optional[AccessBlob] + + +class Input(BaseModel): + input: Data + + +class Decision(BaseModel): + result: Union[List[str], bool] + + +class ExternalPolicyDecisionPoint(AccessPolicy): + def __init__( + self, + authorization_provider: HttpUrl, + node_access: str, + filter_nodes: str, + scopes_access: str, + audience: str, + provider: Optional[str] = None, + ): + """ + Initialize an access policy configuration. + + Parameters + ---------- + authorization_provider : HttpUrl + The base URL of the authorization provider. + node_access : str + The endpoint path for node access validation. Will be joined with the + authorization_provider base URL. + filter_nodes : str + The endpoint path for filtering nodes. Will be joined with the + authorization_provider base URL. + scopes_access : str + The endpoint path for scopes access validation. Will be joined with the + authorization_provider base URL. + audience : str + The intended audience for the authorization tokens. + provider : Optional[str], optional + The name of the authorization provider, by default None. + + Notes + ----- + The endpoint paths are combined with the authorization_provider URL using + urljoin, extracting only the path component from each endpoint parameter. + """ + self._node_access = str(authorization_provider) + node_access + self._filter_nodes = str(authorization_provider) + filter_nodes + self._scopes_access = str(authorization_provider) + scopes_access + self._audience = audience + self._provider = provider + + async def _get_external_decision( + self, + decision_endpoint: str, + principal: Principal, + access_blob: Optional[AccessBlob] = None, + ) -> Optional[Union[List[str], bool]]: + input = Input( + input=Data( + token=self._identifier(principal), + audience=self._audience, + access_blob=access_blob, + ) + ) + async with httpx.AsyncClient() as client: + response = await client.post( + decision_endpoint, content=input.model_dump_json(exclude_none=True) + ) + response.raise_for_status() + try: + return Decision.model_validate_json(response.text).result + except ValidationError: + return None + + def _identifier(self, principal: Principal) -> str: + if principal.type == PrincipalType.service: + return str(principal.uuid) + elif principal.type == PrincipalType.external: + if not principal.access_token: + raise ValueError( + "Access token not provided for external principal type" + ) + return principal.access_token.get_secret_value() + else: + for identity in principal.identities: + if identity.provider == self._provider: + return identity.id + else: + raise ValueError( + f"Principal {principal} has no identity from provider {self._provider}." + f"The Principal's identities are: {principal.identities}" + ) + + async def init_node( + self, + principal: Principal, + authn_access_tags: Optional[AccessTags], + authn_scopes: Scopes, + access_blob: Optional[AccessBlob] = None, + ) -> Tuple[bool, Optional[AccessBlob]]: + decision = await self._get_external_decision( + self._node_access, principal, access_blob + ) + if not decision: + raise ValueError("Permission denied not able to add the node") + return (True, access_blob) + + async def modify_node( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: Optional[AccessTags], + authn_scopes: Scopes, + access_blob: Optional[AccessBlob], + ) -> Tuple[bool, Optional[AccessBlob]]: + if access_blob == node.access_blob: + logger.info( + f"Node access_blob not modified; access_blob is identical: {access_blob}" + ) + return (False, node.access_blob) + decision = await self._get_external_decision( + self._node_access, principal, access_blob + ) + if not decision: + raise ValueError("Permission denied not able to add the node") + return (True, access_blob) + + async def filters( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: Optional[AccessTags], + authn_scopes: Scopes, + scopes: Scopes, + ) -> Filters: + queries = [] + query_filter = AccessBlobFilter + result = await self._get_external_decision(self._filter_nodes, principal) + if isinstance(result, List): + queries.append(query_filter(tags=result, user_id=None)) + return queries + + async def allowed_scopes( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: Optional[AccessTags], + authn_scopes: Scopes, + ) -> Scopes: + access_blob = node.access_blob if hasattr(node, "access_blob") else None + scopes = await self._get_external_decision( + self._scopes_access, principal, access_blob + ) + if isinstance(scopes, List): + return set(scopes) + else: + return NO_SCOPES diff --git a/tiled/authenticators.py b/tiled/authenticators.py index 82f7dcf0e..744a64ec0 100644 --- a/tiled/authenticators.py +++ b/tiled/authenticators.py @@ -263,6 +263,14 @@ class ProxiedOIDCAuthenticator(OIDCAuthenticator): type: string well_known_uri: type: string + scopes: + type: array + items: + type: string + description: | + Optional list of OAuth2 scopes to request. If provided, authorization + should be enforced by an external policy agent (for example ExternalPolicyDecisionPoint) + rather than by this authenticator. device_flow_client_id: type: string confirmation_message: @@ -275,6 +283,7 @@ def __init__( client_id: str, well_known_uri: str, device_flow_client_id: str, + scopes: Optional[List[str]] = None, confirmation_message: str = "", ): super().__init__( @@ -284,6 +293,7 @@ def __init__( well_known_uri=well_known_uri, confirmation_message=confirmation_message, ) + self.scopes = scopes self.device_flow_client_id = device_flow_client_id self._oidc_bearer = OAuth2AuthorizationCodeBearer( authorizationUrl=str(self.authorization_endpoint), diff --git a/tiled/client/context.py b/tiled/client/context.py index cc32bc02d..3dbe18186 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -291,9 +291,6 @@ def __init__( else None ) - if self.client_id: - client.headers = {"Content-Type": "application/x-www-form-urlencoded"} - def __repr__(self): auth_info = [] if (self.api_key is None) and (self.http_client.auth is None): diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index a4c19a297..c2f344037 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -413,8 +413,23 @@ async def check_scopes( request: Request, security_scopes: SecurityScopes, scopes: set[str] = Depends(get_current_scopes), + settings: Settings = Depends(get_settings), ) -> None: - if not set(security_scopes.scopes).issubset(scopes): + if ( + isinstance(settings.authenticator, ProxiedOIDCAuthenticator) + and settings.authenticator.scopes + ): + if not set(settings.authenticator.scopes).issubset(scopes): + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail=( + "Not enough permissions. " + f"Requires scopes {settings.authenticator.scopes}. " + f"Request had scopes {list(scopes)}" + ), + headers=headers_for_401(request, security_scopes), + ) + elif not set(security_scopes.scopes).issubset(scopes): raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail=( @@ -493,6 +508,7 @@ async def get_current_principal_websocket( async def get_current_principal( request: Request, security_scopes: SecurityScopes, + access_token: str = Depends(oauth2_scheme), decoded_access_token: str = Depends(get_decoded_access_token), api_key: str = Depends(get_api_key), settings: Settings = Depends(get_settings), @@ -545,6 +561,7 @@ async def get_current_principal( uuid=uuid_module.UUID(hex=decoded_access_token["sub"]), type=schemas.PrincipalType.external, identities=[], + access_token=access_token, ) else: # No form of authentication is present. diff --git a/tiled/server/schemas.py b/tiled/server/schemas.py index 154e67521..7271dbe64 100644 --- a/tiled/server/schemas.py +++ b/tiled/server/schemas.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar, Union import pydantic.generics -from pydantic import ConfigDict, Field, StringConstraints +from pydantic import ConfigDict, Field, SecretStr, StringConstraints from pydantic_core import PydanticCustomError from typing_extensions import Annotated, TypedDict @@ -381,6 +381,7 @@ class Principal(pydantic.BaseModel): api_keys: List[APIKey] = [] sessions: List[Session] = [] latest_activity: Optional[datetime] = None + access_token: Optional[SecretStr] = Field(exclude=True, default=None) @classmethod def from_orm(