PR #102 wired apply_schema_as. This PR completes the chassis-side coverage so every public mutating engine entry point hits the same Omnigraph::enforce(action, scope, actor) gate regardless of transport: - mutate_as → enforce(Change, Branch(branch), actor) - load_as → enforce(Change, Branch(branch), actor) - ingest_as → enforce(Change, Branch(branch), actor); also threads actor through the implicit branch_create_from_as so fresh-branch ingest correctly hits BranchCreate too - branch_create_as → enforce(BranchCreate, TargetBranch(name), actor) - branch_create_from_as → enforce(BranchCreate, BranchTransition { source, target }, actor) - branch_delete_as → enforce(BranchDelete, TargetBranch(name), actor) - branch_merge_as → enforce(BranchMerge, BranchTransition { source, target }, actor) Three new _as variants for branch ops (create, create_from, delete) that had no actor surface before; existing actor-less variants delegate with actor=None so the no-policy path is a strict no-op. HTTP handlers updated to thread the resolved actor into the new _as variants for branch_create and branch_delete (was previously dropped). 14 new SDK chassis tests (one allow + one deny pair per wired writer); the existing 4 apply_schema_as tests stay. All 18 pass. docs/user/policy.md updated to describe engine-wide enforcement and the coarse-vs-fine layer split (engine = action gate, query layer per-row = MR-725 future). AGENTS.md capability matrix updated to match. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
4.5 KiB
Authorization (Cedar policy)
OmniGraph integrates AWS Cedar (cedar-policy = 4.9) for ABAC.
Policy actions
read— query / snapshot / list branches & commitsexport— NDJSON exportchange— mutationsschema_apply— apply schema migrationsbranch_createbranch_deletebranch_mergeadmin— reserved for policy-management surfaces (hot reload, audit log, approvals). No call site today; see MR-724 for the reservation rationale.
Scope kinds
branch_scope— applied to source branch (read,export,change)target_branch_scope— applied to destination (schema_apply, branch ops, run ops)protected_branches— named list with special rules; rule scopes areany | protected | unprotected
Configuration
omnigraph.yaml:
policy:
file: ./policy.yaml # Cedar rules + groups
tests: ./policy.tests.yaml # declarative test cases
Each rule must use exactly one of branch_scope or target_branch_scope.
CLI
omnigraph policy validate— parse + count actors, exit 1 on parse error.omnigraph policy test— run cases inpolicy.tests.yaml, exit 1 on any expectation mismatch.omnigraph policy explain --actor … --action … [--branch …] [--target-branch …]— show decision and matched rule.
Enforcement
Policy is a property of the engine, not the transport. Every mutating
write — mutate_as, load_as, ingest_as, apply_schema_as,
branch_create_as, branch_create_from_as, branch_delete_as,
branch_merge_as — calls Omnigraph::enforce(action, scope, actor) at
the head of the method. The gate fires identically whether the call
originates from the HTTP server, the CLI, or an embedded SDK consumer.
When no PolicyChecker is installed (the dev/embedded default) the gate
is a strict no-op; when one is installed and the call site forgets to
thread an actor through, the gate fails closed rather than silently
bypassing.
Server-side, authorize_request() still runs at the HTTP boundary —
that's where actor identity is resolved from the bearer token and where
admission control / per-actor rate limits live. Engine-layer enforcement
is the defense in depth layer: it catches CLI direct-engine writes,
embedded SDK consumers, and any future transport that hasn't (or won't)
re-implement HTTP's authorize_request. Both layers consult the same
Cedar policy via the same PolicyChecker trait, so decisions cannot
disagree.
Coarse vs. fine enforcement
There are two enforcement points, each with non-overlapping responsibilities:
| Layer | Question it answers | Where it fires |
|---|---|---|
| Engine-layer (coarse) | Can this actor invoke this action against this branch / branch-transition? | Omnigraph::enforce(action, scope, actor) at the head of every _as writer; one Cedar decision per call. |
| Query-layer (fine) | For the rows / types this action actually touches, which can the actor see or modify? | Per-row predicates pushed into DataFusion at plan time. Not yet implemented — see MR-725. |
The engine-layer gate keeps ResourceScope deliberately at branch
granularity (Graph, Branch, TargetBranch, BranchTransition).
Per-type and per-row authority is the query-layer's job; conflating them
in ResourceScope would create two places per-type policy could be
evaluated and a drift surface between them.
Actor identity (signed-claim-only)
The actor identity used for every policy decision comes from the matched bearer token — never from a client-supplied request header, query parameter, or body field. The server resolves the token at the auth middleware boundary, looks up the actor it was minted for, and overwrites whatever the handler may have placed in the policy request. Clients cannot set actor_id directly.
This is intentional. Trusting client-supplied identity for authorization is "asking the attacker if they're an admin" — Supabase's RLS history names the same footgun. The chokepoint lives in authorize_request in crates/omnigraph-server/src/lib.rs and is named in docs/dev/invariants.md Hard Invariant 11. A regression test asserts the contract: a request with Authorization: Bearer <token-for-actor-A> plus X-Actor-Id: actor-B always evaluates as actor A, never as actor B.
If you find yourself wanting to let clients override actor_id for impersonation, delegation, or service-account flows — that's a feature, but it needs explicit design (e.g., signed delegation claims, an On-Behalf-Of audit trail). It is not a convenience knob.