Repository navigation
Add per-resource access control primitives and run scopes - #387
Open
morgan-wowk wants to merge 1 commit into
Open
morgan-wowk wants to merge 1 commit into
morgan-wowk wants to merge 1 commit into
Conversation
morgan-wowk
force-pushed
the
access-control-mvp
branch
2 times, most recently
from
October 8, 2026 03:52
340985d to
b2406cf
Compare
yuechao-qin
reviewed
Oct 15, 2026
yuechao-qin
reviewed
Oct 15, 2026
yuechao-qin
left a comment
Collaborator
There was a problem hiding this comment.
(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 |
morgan-wowk
force-pushed
the
access-control-mvp
branch
3 times, most recently
from
October 15, 2026 23:51
2fe880d to
8cc078f
Compare
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.
morgan-wowk
force-pushed
the
access-control-mvp
branch
from
October 20, 2026 02:42
8cc078f to
37c2600
Compare
yuechao-qin
approved these changes
Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
operaterun:cancel,run:annotateeditpipeline:update(rename included)managepipeline:delete,pipeline:shareOwners 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 --> grantsAPI model
access_control/scopes.pyregister_resource_type(name, operate, edit, manage),ResourceType.expand(scope=...)access_control/checks.pyeffective_scopes(...),require(...)raisesNotAuthorizedErroraccess_control/edits.pyapply_users_edit(..., expected_revision)raisesStaleRevisionError,latest_revision(access_control=...)access_control/keys.pyKey: theextra_data.access_controlfield namesaccess_control/runs.pyRunScope,register_run_parent_resolver(resolver=..., annotation_keys=...),require_run_scope(...),require_annotation_change(...)terminatechecksrun:cancel, and setting or deleting an annotation checksrun:annotate. When no resolver is registered, the rule stays the run starter or an admin. Admins reach these throughskip_user_check, as before.Behaviour worth knowing:
ApiValidationError.NotAuthorizedErrordoesn't name the owner in its message. The owner is carried as.owner, and the host decides whether to show it.apply_users_editdoesn'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.run:annotatecan'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 }Related
🤖 Generated with Claude Code