From 2646348e0c9e6e99a069e57963fe2da65d6547f0 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:59:02 +0100 Subject: [PATCH] feat: update tiled policy to support layered node --- policy/diamond/policy/tiled/tiled.rego | 218 ++++++++++++++------ policy/diamond/policy/tiled/tiled_test.rego | 123 +++++++++-- policy/diamond/policy/ulims/ulims_test.rego | 6 +- 3 files changed, 266 insertions(+), 81 deletions(-) diff --git a/policy/diamond/policy/tiled/tiled.rego b/policy/diamond/policy/tiled/tiled.rego index 17644b2..65b3e4e 100644 --- a/policy/diamond/policy/tiled/tiled.rego +++ b/policy/diamond/policy/tiled/tiled.rego @@ -1,12 +1,21 @@ package diamond.policy.tiled -import data.diamond.policy.admin +import data.diamond.policy.beamline as beamline_policy +import data.diamond.policy.proposal import data.diamond.policy.session import data.diamond.policy.token import rego.v1 -# Assign read & write scopes to clients with tiled-writer audience -# defaults to read-only scopes +# METADATA +# title: Tiled Scopes +# description: | +# Says what the caller is allowed to do in tiled. +# Anyone who gets this far may read metadata and data. +# Clients whose token carries the "tiled-writer" audience may also write, +# create nodes and register data. +# Requires: +# - `input.token`, a JWT +# entrypoint: true default scopes := { "read:metadata", "read:data", @@ -23,24 +32,129 @@ scopes := { "tiled-writer" in token.claims.aud } -_session := data.diamond.data.proposals[format_int(input.proposal, 10)].sessions[format_int(input.visit, 10)] +# METADATA +# title: Bare Proposal Number +# description: | +# Turns a proposal label into the plain number it is stored under, +# so "cm1234" becomes 1234. +bare_proposal(p) := to_number(trim_left(lower(p), "abcdefghijklmnopqrstuvwxyz")) + +# METADATA +# title: Session Lookup +# description: | +# Finds the session that `input.visit` refers to within proposal `p`. +# Undefined when the proposal has no such visit, which is how the rules +# below check that a session really exists. +_session(p) := data.diamond.data.proposals[format_int(p, 10)].sessions[format_int(input.visit, 10)] + +# METADATA +# title: Beamline Tag +# description: | +# The access tag for a beamline, the top level of the beamline, proposal +# and session tree. +beamline_tag(bl) := sprintf("beamline:%s", [bl]) + +# METADATA +# title: Proposal Tag +# description: | +# The access tag for a proposal. It names the beamline above it as well, +# so that one flat membership check can match a node at any level of the +# tree. `p` is always the bare proposal number. +proposal_tag(bl, p) := sprintf("beamline:%s,proposal:%s", [bl, format_int(p, 10)]) + +# METADATA +# title: Session Tag +# description: | +# The access tag for a session, the leaf of the tree. Like the proposal +# tag it names every ancestor, and it identifies the session by proposal +# and visit, for example "beamline:i03,proposal:1,session:1-1". +session_tag(bl, p, v) := sprintf("beamline:%s,proposal:%s,session:%s-%d", [bl, format_int(p, 10), format_int(p, 10), v]) + +# METADATA +# title: Proposal Beamlines +# description: | +# The beamlines hosting at least one session of the bare proposal number `p`. +proposal_beamlines(p) := {data.diamond.data.sessions[format_int(s, 10)].beamline | + some s in data.diamond.data.proposals[format_int(p, 10)].sessions +} -# Returns the session ID if the subject has write permissions for the -# specific beamline, visit and proposal requested in the input. -user_session := format_int(_session, 10) if { - session.write_to_beamline_visit - _session +# METADATA +# title: Node Access Tag +# description: | +# The access tag to stamp on the node the caller is creating, or nothing +# at all if they are not allowed to create it. +# tiled's create-node flow (see `init_node` in dls.py) tells the policy +# nothing about the path or the parent, so the only clue to which level is +# being created is which fields arrive alongside the beamline: +# - beamline alone, a beamline node +# - beamline and proposal, a proposal node +# - beamline, proposal and visit, a session node +# The three levels are kept apart by checking that the deeper fields are +# absent. The first rule below is the session level for a named user, who +# must have write access to the beamline, proposal and visit they name, and +# the rules after it cover the other two levels and the service accounts. +# Requires: +# - `input.token`, a JWT +# - `input.beamline`, the beamline the node belongs to +# - `input.proposal`, a proposal label such as "cm1234", for the lower +# two levels +# - `input.visit`, a visit number, for a session node +# entrypoint: true +user_session := session_tag(input.beamline, p, input.visit) if { + input.visit + p := bare_proposal(input.proposal) + session.access_session(token.claims.fedid, p, input.visit) + input.beamline == session.beamline_for(p, input.visit) + _session(p) } -# service account check -user_session := format_int(_session, 10) if { +# Session level, for a service account tied to the beamline. +user_session := session_tag(input.beamline, p, input.visit) if { + input.visit + not token.claims.fedid input.beamline == token.claims.beamline - input.beamline == session.beamline_for(input.proposal, input.visit) - _session in data.diamond.data.beamlines[input.beamline].sessions + p := bare_proposal(input.proposal) + input.beamline == session.beamline_for(p, input.visit) + _session(p) in data.diamond.data.beamlines[input.beamline].sessions +} + +# Proposal level: the caller has access to the proposal, and the proposal +# actually runs on the beamline they asked for. +user_session := proposal_tag(input.beamline, p) if { + not input.visit + input.proposal + p := bare_proposal(input.proposal) + proposal.access_proposal(token.claims.fedid, p) + input.beamline in proposal_beamlines(p) +} + +# Proposal level, for a service account tied to the beamline. +user_session := proposal_tag(input.beamline, p) if { + not input.visit + input.proposal + not token.claims.fedid + input.beamline == token.claims.beamline + p := bare_proposal(input.proposal) + input.beamline in proposal_beamlines(p) +} + +# Beamline level: the caller is authorized on the beamline itself. +user_session := beamline_tag(input.beamline) if { + not input.visit + not input.proposal + input.beamline in beamline_policy.user_beamlines } -# Validates if the subject has permission to modify -# the specific session in the input. +# METADATA +# title: Modify Session +# description: | +# Whether the caller may change an existing session. A named user needs +# access to that session; a service account needs the session to sit on +# the beamline named in its token. +# Requires: +# - `input.token`, a JWT +# - `input.session`, a session id +# entrypoint: true default modify_session := false modify_session if session.access_session( @@ -49,7 +163,7 @@ modify_session if session.access_session( data.diamond.data.sessions[input.session].visit_number, ) -# service account check +# Service accounts may modify any session on their own beamline. modify_session if { not token.claims.fedid session.beamline_for( @@ -58,52 +172,36 @@ modify_session if { ) == token.claims.beamline } -subject := data.diamond.data.subjects[token.claims.fedid] - -# Identifies all beamlines the subject is authorized to access -# based on their assigned permissions. -beamlines contains beamline if { - token.claims.fedid - not admin.is_admin(token.claims.fedid) - some p in subject.permissions - some beamline in object.get(data.diamond.data.admin, p, []) -} - -# Aggregates all session IDs the subject is authorized to view. -# Admins receive a wildcard "*" granting access to all sessions. - -# Regular users gain session access through three pathways: -# 1. Direct session membership -# 2. Access via beamline-level permissions -# 3. Access via proposal-level permissions -user_sessions contains "*" if { - subject - admin.is_admin(token.claims.fedid) -} - -user_sessions contains format_int(session, 10) if { - subject - not admin.is_admin(token.claims.fedid) - some session in subject.sessions -} - -user_sessions contains format_int(session, 10) if { - subject - not admin.is_admin(token.claims.fedid) - some beamline in beamlines - some session in data.diamond.data.beamlines[beamline].sessions +# METADATA +# title: Accessible Node Tags +# description: | +# Every access tag the caller is allowed to see. tiled asks for these when +# listing the tree and keeps the nodes whose own tag is in the set. +# Each session the caller can reach contributes three tags, one for itself +# and one for each of its ancestors, so the beamline and proposal that +# contain a session stay visible alongside the session itself. +# Admins get "*", which stands in for every tag. +# The tags are rebuilt from the numbers in the data bundle, exactly as they +# are built when a node is created, so the two always agree. +# Requires: +# - `input.token`, a JWT +# entrypoint: true +user_sessions contains "*" if "*" in session.user_sessions + +user_sessions contains beamline_tag(s.beamline) if { + not "*" in session.user_sessions + some id in session.user_sessions + s := data.diamond.data.sessions[id] } -user_sessions contains format_int(session, 10) if { - subject - not admin.is_admin(token.claims.fedid) - some p in subject.proposals - some i in data.diamond.data.proposals[format_int(p, 10)] - some session in i +user_sessions contains proposal_tag(s.beamline, s.proposal_number) if { + not "*" in session.user_sessions + some id in session.user_sessions + s := data.diamond.data.sessions[id] } -# service account check -user_sessions contains format_int(session, 10) if { - not subject - some session in data.diamond.data.beamlines[token.claims.beamline].sessions +user_sessions contains session_tag(s.beamline, s.proposal_number, s.visit_number) if { + not "*" in session.user_sessions + some id in session.user_sessions + s := data.diamond.data.sessions[id] } diff --git a/policy/diamond/policy/tiled/tiled_test.rego b/policy/diamond/policy/tiled/tiled_test.rego index 8818c57..e56482d 100644 --- a/policy/diamond/policy/tiled/tiled_test.rego +++ b/policy/diamond/policy/tiled/tiled_test.rego @@ -7,8 +7,7 @@ test_read_scopes if { tiled.scopes == { "read:metadata", "read:data", - } - with data.diamond.policy.token.claims as {} + } with data.diamond.policy.token.claims as {} } test_tiled_writer_given_write_scopes if { @@ -19,8 +18,7 @@ test_tiled_writer_given_write_scopes if { "write:data", "create:node", "register", - } - with data.diamond.policy.token.claims as {"aud": ["tiled-writer"]} + } with data.diamond.policy.token.claims as {"aud": ["tiled-writer"]} } diamond_data := { @@ -95,27 +93,54 @@ diamond_data := { test_user_session_tags if { tiled.user_sessions == set() with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "oscar"} - tiled.user_sessions == {"11", "12"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:i03", + "beamline:i03,proposal:1", + "beamline:i03,proposal:1,session:1-1", + "beamline:b07", + "beamline:b07,proposal:1", + "beamline:b07,proposal:1,session:1-2", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "alice"} - tiled.user_sessions == {"11", "12", "13", "14"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:i03", + "beamline:i03,proposal:1", + "beamline:i03,proposal:1,session:1-1", + "beamline:b07", + "beamline:b07,proposal:1", + "beamline:b07,proposal:1,session:1-2", + "beamline:b07,proposal:2", + "beamline:b07,proposal:2,session:2-1", + "beamline:b07,proposal:2,session:2-2", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "bob"} tiled.user_sessions == {"*"} with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "carol"} - tiled.user_sessions == {"13", "14"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:b07", + "beamline:b07,proposal:2", + "beamline:b07,proposal:2,session:2-1", + "beamline:b07,proposal:2,session:2-2", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "desmond"} - tiled.user_sessions == {"13", "14"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:b07", + "beamline:b07,proposal:2", + "beamline:b07,proposal:2,session:2-1", + "beamline:b07,proposal:2,session:2-2", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"fedid": "edna"} } test_user_session_allow if { - tiled.user_session == "11" with data.diamond.data as diamond_data - with input as {"beamline": "i03", "proposal": 1, "visit": 1} + tiled.user_session == "beamline:i03,proposal:1,session:1-1" with data.diamond.data as diamond_data + with input as {"beamline": "i03", "proposal": "cm1", "visit": 1} with data.diamond.policy.token.claims as {"fedid": "carol"} } test_user_session_not_allowed if { not tiled.user_session with data.diamond.data as diamond_data - with input as {"beamline": "i03", "proposal": 1, "visit": 1} + with input as {"beamline": "i03", "proposal": "cm1", "visit": 1} with data.diamond.policy.token.claims as {"fedid": "oscar"} } @@ -134,23 +159,32 @@ test_modify_session if { # Service account tests test_user_session_allow_service_account_on_beamline if { - tiled.user_session == "11" with data.diamond.data as diamond_data - with input as {"beamline": "i03", "proposal": 1, "visit": 1} + tiled.user_session == "beamline:i03,proposal:1,session:1-1" with data.diamond.data as diamond_data + with input as {"beamline": "i03", "proposal": "cm1", "visit": 1} with data.diamond.policy.token.claims as {"beamline": "i03"} } test_user_session_not_allow_service_account_wrong_beamline if { not tiled.user_session with data.diamond.data as diamond_data - with input as {"beamline": "i03", "proposal": 1, "visit": 2} + with input as {"beamline": "i03", "proposal": "cm1", "visit": 2} with data.diamond.policy.token.claims as {"beamline": "b07"} } test_user_session_not_allow_service_account_on_none_existent_beamline_beamline if { not tiled.user_session with data.diamond.data as diamond_data - with input as {"beamline": "i03", "proposal": 1, "visit": 2} + with input as {"beamline": "i03", "proposal": "cm1", "visit": 2} with data.diamond.policy.token.claims as {"beamline": "b007"} } +# A token carrying both fedid and beamline claims should still be judged as +# a named user (fedid's real permissions), not fall into the service-account +# branch just because the beamline claim happens to match the input. +test_user_session_not_allow_named_user_with_beamline_claim_and_no_access if { + not tiled.user_session with data.diamond.data as diamond_data + with input as {"beamline": "b07", "proposal": "cm2", "visit": 1} + with data.diamond.policy.token.claims as {"fedid": "alice", "beamline": "b07"} +} + test_modify_session_on_beamline if { tiled.modify_session with data.diamond.data as diamond_data with input as {"session": "11"} @@ -170,10 +204,65 @@ test_modify_session_on_none_existent_beamline if { } test_user_session_tags_service_account if { - tiled.user_sessions == {"11"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:i03", + "beamline:i03,proposal:1", + "beamline:i03,proposal:1,session:1-1", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"beamline": "i03"} - tiled.user_sessions == {"12", "13", "14"} with data.diamond.data as diamond_data + tiled.user_sessions == { + "beamline:b07", + "beamline:b07,proposal:1", + "beamline:b07,proposal:1,session:1-2", + "beamline:b07,proposal:2", + "beamline:b07,proposal:2,session:2-1", + "beamline:b07,proposal:2,session:2-2", + } with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"beamline": "b07"} tiled.user_sessions == set() with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"beamline": "b007"} } + +# Level 1 & 2 node creation + +test_user_session_beamline_level_allow if { + tiled.user_session == "beamline:b07" with data.diamond.data as diamond_data + with input as {"beamline": "b07"} + with data.diamond.policy.token.claims as {"fedid": "bob"} +} + +test_user_session_beamline_level_denied if { + not tiled.user_session with data.diamond.data as diamond_data + with input as {"beamline": "b07"} + with data.diamond.policy.token.claims as {"fedid": "alice"} +} + +test_user_session_beamline_level_allow_service_account if { + tiled.user_session == "beamline:i03" with data.diamond.data as diamond_data + with input as {"beamline": "i03"} + with data.diamond.policy.token.claims as {"beamline": "i03"} +} + +test_user_session_proposal_level_allow if { + tiled.user_session == "beamline:i03,proposal:1" with data.diamond.data as diamond_data + with input as {"beamline": "i03", "proposal": "cm1"} + with data.diamond.policy.token.claims as {"fedid": "alice"} +} + +test_user_session_proposal_level_denied_wrong_beamline if { + not tiled.user_session with data.diamond.data as diamond_data + with input as {"beamline": "i04", "proposal": "cm1"} + with data.diamond.policy.token.claims as {"fedid": "alice"} +} + +test_user_session_proposal_level_denied_no_access if { + not tiled.user_session with data.diamond.data as diamond_data + with input as {"beamline": "i03", "proposal": "cm1"} + with data.diamond.policy.token.claims as {"fedid": "oscar"} +} + +test_user_session_proposal_level_allow_service_account if { + tiled.user_session == "beamline:b07,proposal:2" with data.diamond.data as diamond_data + with input as {"beamline": "b07", "proposal": "cm2"} + with data.diamond.policy.token.claims as {"beamline": "b07"} +} diff --git a/policy/diamond/policy/ulims/ulims_test.rego b/policy/diamond/policy/ulims/ulims_test.rego index ed565f2..ba965d4 100644 --- a/policy/diamond/policy/ulims/ulims_test.rego +++ b/policy/diamond/policy/ulims/ulims_test.rego @@ -89,8 +89,7 @@ test_session_restrictions_for_non_admin_1 if { "proposal_number": 1, "visit_number": 2, }, - ] - with data.diamond.data as diamond_data + ] with data.diamond.data as diamond_data with data.diamond.policy.token as {"claims": {"fedid": "alice"}} } @@ -104,8 +103,7 @@ test_session_restrictions_service_account if { "beamline": "i03", "proposal_number": 1, "visit_number": 1, - }] - with data.diamond.data as diamond_data + }] with data.diamond.data as diamond_data with data.diamond.policy.token.claims as {"beamline": "i03"} }