Skip to content

Add per-resource access control primitives and run scopes - #387

Open
morgan-wowk wants to merge 1 commit into
masterfrom
access-control-mvp
Open

morgan-wowk wants to merge 1 commit into
masterfrom
access-control-mvp

Conversation

@morgan-wowk

@morgan-wowk morgan-wowk commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Per-resource access control primitives: scopes, grant checks, a revisioned edit history, and run scopes inherited from a parent resource. Host apps register their resource types and a run-parent resolver.

oasis-backend registers one resource type, saved pipelines:

Scope Includes
operate run:cancel, run:annotate
edit operate, plus pipeline:update (rename included)
manage edit, plus pipeline:delete, pipeline:share

Owners and admins hold every scope. Runs inherit run:* from their saved pipeline. Starting a run isn't a scope: anyone can run a saved pipeline, as on prod today.

Solution

flowchart TD
  call["API call on a resource"] --> who{"caller is owner or admin?"}
  who -- yes --> allow["allowed"]
  who -- no --> grants["read extra_data.access_control.users"]
  grants --> expand["expand broad scopes: operate, edit, manage"]
  expand --> has{"scope present?"}
  has -- yes --> allow
  has -- no --> deny["403"]
  run["run action: cancel or annotate"] --> starter{"caller started the run?"}
  starter -- yes --> allow
  starter -- no --> parent["pipeline-id annotation to parent pipeline"]
  parent --> grants
Loading

API model

Module Entry points
access_control/scopes.py register_resource_type(name, operate, edit, manage), ResourceType.expand(scope=...)
access_control/checks.py effective_scopes(...), require(...) raises NotAuthorizedError
access_control/edits.py apply_users_edit(..., expected_revision) raises StaleRevisionError, latest_revision(access_control=...)
access_control/keys.py Key: the extra_data.access_control field names
access_control/runs.py RunScope, register_run_parent_resolver(resolver=..., annotation_keys=...), require_run_scope(...), require_annotation_change(...)

terminate checks run:cancel, and setting or deleting an annotation checks run:annotate. When no resolver is registered, the rule stays the run starter or an admin. Admins reach these through skip_user_check, as before.

Behaviour worth knowing:

  • Re-registering a resource type with the same scopes does nothing. Re-registering it with different scopes raises ApiValidationError.
  • NotAuthorizedError doesn't name the owner in its message. The owner is carried as .owner, and the host decides whether to show it.
  • History keeps the newest 100 entries. The revision keeps counting from the last entry.
  • apply_users_edit doesn't lock anything. It only checks the revision of the snapshot it is given. The caller must hold a row lock from the read through the write. A test shows two unlocked writers at the same revision both succeeding.
  • Parent annotations can't be re-pointed by grantees. A resolver names the annotation keys it reads. Only the run's starter and admins can set or delete those keys, so someone granted run:annotate can't move a run to a pipeline that gives them more.

Data model

erDiagram
  resource ||--o| access_control : "extra_data"
  access_control ||--o{ user_grant : "users"
  access_control ||--o{ history_entry : "history"
  history_entry ||--|{ change : "changes"
  pipeline_run }o--o| resource : "RunParent resolver"
  resource {
    string id
    string owner
    json extra_data
  }
  pipeline_run {
    string id
    string created_by "run starter"
    json annotations
  }
  user_grant {
    string email
    string_list permissions
  }
  history_entry {
    int revision
    string changed_by
    datetime changed_at
  }
  change {
    string email
    string_list from
    string_list to
  }
Loading

Related

🤖 Generated with Claude Code

Comment thread cloud_pipelines_backend/access_control/checks.py Outdated
Comment thread cloud_pipelines_backend/access_control/edits.py Outdated
Comment thread cloud_pipelines_backend/access_control/edits.py

@yuechao-qin yuechao-qin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(AI Assisted)

Approve with changes. The scope model is small and fails closed, the tier expansion is right, and the three api_server_sql call sites are converted faithfully — NotAuthorizedError subclasses errors.PermissionError (checks.py:8), so no caller's except breaks. Two things block my sign-off rather than the merge, and both are library contract problems rather than bugs in the one host that exists today: expected_revision is advertised as concurrency control and is not, and the history it writes has no bound.

# Severity Topic
M1 🔶 major expected_revision reads as optimistic concurrency control, but two writers at the same revision both succeed
M2 🔶 major the history this module writes has no bound, and every authorization check pays for it
D1 🛠 design run:submit is granted but enforced nowhere, in either repo
m1 🔹 minor register_resource_type silently overwrites, while its sibling registrar guards against duplicates
m2 🔹 minor the authorization failure message discloses the owner's email to a caller who just failed the check
m3 🔹 minor every new call site builds a Caller without is_admin, so the admin branch in run_scopes is unreachable
N1 ▫️ nit run_scopes returns str elements on one branch and RunScope members on the other

Comment thread cloud_pipelines_backend/access_control/edits.py
Comment thread cloud_pipelines_backend/access_control/edits.py
Comment thread cloud_pipelines_backend/access_control/runs.py Outdated
Comment thread cloud_pipelines_backend/access_control/scopes.py
Comment thread cloud_pipelines_backend/access_control/checks.py Outdated
Comment thread cloud_pipelines_backend/api_server_sql.py
Comment thread cloud_pipelines_backend/access_control/runs.py Outdated
@morgan-wowk
morgan-wowk force-pushed the access-control-mvp branch 3 times, most recently from 2fe880d to 8cc078f Compare October 15, 2026 23:51
Comment thread cloud_pipelines_backend/access_control/runs.py
Adds cloud_pipelines_backend/access_control, the OSS half of the RBAC MVP:

- scopes: broad scopes (operate < edit < manage) and a registry where a host
  declares each resource type's fine scopes per tier. Broad scopes are expanded
  at check time. Re-registering a type with the same scopes is a no-op; with
  different scopes it raises.
- checks: effective_scopes/require for a caller against a resource's owner and
  its extra_data["access_control"]. Owner and admins hold everything. Emails
  are normalized. Unknown scopes are ignored on read. NotAuthorizedError keeps
  the owner off its message and carries it as `owner`, so the host decides
  whether to show it.
- keys: the access_control field names (users, permissions, history, revision,
  changed_by, changed_at, changes, from, to).
- edits: apply_users_edit validates a full replacement of the user list (strict:
  unknown scopes, the owner, duplicates and blank emails are rejected), rejects
  a stale revision, and appends one history entry holding the per-user
  {from, to} diff. History keeps the newest 100 entries; the revision keeps
  counting from the last one. The revision check only sees the snapshot it is
  given, so the caller must hold a row lock across the read and the write.
- runs: run:cancel / run:annotate. A run's starter and admins hold both; anyone
  else inherits run:* from parents supplied by host-registered resolvers.
  Nothing is copied onto the run. A resolver names the annotations it reads;
  only the starter and admins may set or delete those, so a grantee holding
  run:annotate can't re-parent a run. Starting a run is not a scope: anyone who
  can read a pipeline can still run it.

PipelineRunsApiService_Sql.terminate/set_annotation/delete_annotation now ask
require_run_scope / require_annotation_change instead of comparing created_by. Admins still reach these
through skip_user_check; Caller.is_admin is for hosts that call the checks
directly. With no resolver registered the behaviour is the previous
owner-or-admin rule, except owner comparison is now case-insensitive.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants