Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion example_configs/keycloak_oidc/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
26 changes: 25 additions & 1 deletion example_configs/keycloak_oidc/README.md
Original file line number Diff line number Diff line change
@@ -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.**
16 changes: 15 additions & 1 deletion example_configs/keycloak_oidc/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
32 changes: 28 additions & 4 deletions example_configs/keycloak_oidc/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
9 changes: 9 additions & 0 deletions example_configs/keycloak_oidc/example_data.py
Original file line number Diff line number Diff line change
@@ -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")
98 changes: 98 additions & 0 deletions example_configs/keycloak_oidc/policy/rbac/rbac.rego
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tag configures what unauthenticated requests can do. Generally I would expect those would never be allowed to write (or create, register, delete).

"read:metadata",
"write:metadata",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intent here to make public nodes world-writable? Generally I would expect world-readable, but not world-writable.

"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
}
109 changes: 109 additions & 0 deletions example_configs/keycloak_oidc/policy/rbac/rbac_test.rego
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions example_configs/keycloak_oidc/policy/token/token.rego
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading