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
2 changes: 1 addition & 1 deletion .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ services:
BUNDLER_OTEL_COLLECTOR_URL: http://collector:4317

opa:
image: docker.io/openpolicyagent/opa:0.59.0
image: docker.io/openpolicyagent/opa:1.21.2
restart: unless-stopped
command: >
run
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/_policy_container.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2.2.0
with:
version: latest
version: 1.21.2

- name: Build OPA Policy
run: >
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/_policy_lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Setup Regal
uses: open-policy-agent/setup-regal@761188c3b435761fa254beca508a44875619648f #v2.0.0
with:
version: latest
version: 0.43.0

- name: Lint
run: >
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/_policy_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2.2.0
with:
version: latest
version: 1.21.2

- name: Test
run: opa test ./policy -v
4 changes: 2 additions & 2 deletions policy/diamond/policy/admin/admin_test.rego
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ test_is_admin_for_beamline_admin_not_admin if {
}

test_beamline_admin_for_subject_for_non_beamline_admin if {
not "alice" in admin.beamline_admin_for_subject with data.diamond.data as diamond_data
not admin.beamline_admin_for_subject.alice with data.diamond.data as diamond_data
}

test_beamline_admin_for_subject_for_admin if {
not "carol" in admin.beamline_admin_for_subject with data.diamond.data as diamond_data
not admin.beamline_admin_for_subject.carol with data.diamond.data as diamond_data
}

test_admin_rule_for_admin if {
Expand Down
3 changes: 3 additions & 0 deletions policy/diamond/policy/beamline/beamline.rego
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ user_beamlines contains beamline if {
some beamline in object.keys(data.diamond.data.beamlines)
}

# Service accounts only: a token that also names a user (fedid) is judged on
# that user's own permissions, never on its beamline claim.
user_beamlines contains token.claims.beamline if {
not token.claims.fedid
token.claims.beamline in object.keys(data.diamond.data.beamlines)
}
9 changes: 9 additions & 0 deletions policy/diamond/policy/beamline/beamline_test.rego
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,12 @@ test_user_beamlines_service_account_bad_beamline_claim if {
beamline.user_beamlines == set() with data.diamond.policy.token.claims as {"beamline": "area-51-beamline"}
with data.diamond.data as diamond_data
}

# A beamline claim only counts for service accounts; a named user keeps their
# own beamlines whatever the token's beamline claim says.
test_user_beamlines_named_user_ignores_beamline_claim if {
beamline.user_beamlines == set() with data.diamond.policy.token.claims as {"fedid": "alice", "beamline": "b07"}
with data.diamond.data as diamond_data
beamline.user_beamlines == {"b07"} with data.diamond.policy.token.claims as {"fedid": "bob", "beamline": "i03"}
with data.diamond.data as diamond_data
}
1 change: 0 additions & 1 deletion policy/diamond/policy/blueapi/blueapi.rego
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package diamond.policy.blueapi

import data.diamond.policy.admin
import data.diamond.policy.session
import data.diamond.policy.token

Expand Down
253 changes: 194 additions & 59 deletions policy/diamond/policy/tiled/tiled.rego
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
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",
Expand All @@ -23,24 +33,152 @@ 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"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This accepts any alphabetic prefix, not just cm. Can we tighten this so malformed proposal labels fail closed?


# 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) := $"beamline:{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) := $"beamline:{bl},proposal:{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: Visible Tag
# description: |
# Lets a caller list a beamline or proposal node without being granted
# what is under it. tiled matches tags exactly, so this is a separate tag
# from the grant `t`: a session member gets the visible tags of the
# proposal and beamline above them, never their grant tags.
visible_tag(t) := $"visible:{t}"

# METADATA
# title: Node Tags
# description: |
# The tags stamped on a node at each level: its own tag, the grant tag of
# every ancestor, so one grant matches a whole subtree, and for the two
# container levels, a visible tag so callers granted something below can
# still list it. The node's own tag is first; dls.py reads it back from
# `tags[0]`.
beamline_node_tags(bl) := [beamline_tag(bl), visible_tag(beamline_tag(bl))]

proposal_node_tags(bl, p) := [proposal_tag(bl, p), beamline_tag(bl), visible_tag(proposal_tag(bl, p))]

session_node_tags(bl, p, v) := [session_tag(bl, p, v), proposal_tag(bl, p), beamline_tag(bl)]

# 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 Tags
# description: |
# The access tags to stamp on the node the caller is creating (see Node
# Tags), 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_node_tags(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_node_tags(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
}
Comment on lines 133 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we confirm a token with both claims never falls back to the service-account branch if the named-user check fails?


# Proposal level: the caller has access to the proposal, and the proposal
# actually runs on the beamline they asked for.
user_session := proposal_node_tags(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_node_tags(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_node_tags(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(
Expand All @@ -49,7 +187,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(
Expand All @@ -58,52 +196,49 @@ 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
# Defaulted so `not _is_admin` holds for service accounts, whose token has no
# fedid: `not admin.is_admin(token.claims.fedid)` would be undefined for them.
default _is_admin := false

_is_admin if admin.is_admin(token.claims.fedid)

# METADATA
# title: Accessible Node Tags
# description: |
# The tags the caller may see. tiled asks for these when listing the tree
# and keeps the nodes carrying any of them (an exact match on each tag).
# Access is returned at the level it is granted, not expanded into every
# session below it: nodes carry their ancestors' grant tags (see Node Tags),
# so a beamline grant is one tag however many sessions the beamline has.
# - beamline admins and service accounts: the beamline tag
# - proposal members: the proposal tag, plus the beamline's visible tag
# - session members: the session tag, plus the proposal's and beamline's
# visible tags
# Admins get "*", which stands in for every tag.
# Requires:
# - `input.token`, a JWT
# entrypoint: true
user_sessions contains "*" if _is_admin

user_sessions contains beamline_tag(bl) if {
not _is_admin
some bl in beamline_policy.user_beamlines
}

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 tag if {
not _is_admin
some p in session.subject.proposals
some bl in proposal_beamlines(p)
some tag in [proposal_tag(bl, p), visible_tag(beamline_tag(bl))]
}

# 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 tag if {
not _is_admin
some id in session.subject.sessions
s := data.diamond.data.sessions[format_int(id, 10)]
some tag in [
session_tag(s.beamline, s.proposal_number, s.visit_number),
visible_tag(proposal_tag(s.beamline, s.proposal_number)),
visible_tag(beamline_tag(s.beamline)),
]
}
Loading
Loading