omnigraph/docs/user/policy.md
Ragnor Comerford d11c18fb27
mr-668: composite e2e tests, race fix, v0.7.0 release (PR 9/10)
PR 9 — the final integration PR for MR-668 multi-graph server work.
Closes the v0.7.0 release.

Composite lifecycle tests (closes gaps flagged in PR 7's coverage
review):
  - `multi_graph_lifecycle_post_query_restart_persistence` — POST a
    graph, query it via cluster route, reload the config from disk
    and confirm `load_server_settings` sees the rewritten YAML.
    Validates the "restart resolves orphans" failure-mode story.
  - `per_graph_policy_enforced_on_post_created_graph` — POST a graph
    with a per-graph policy attached, then send authenticated read
    and change requests. Per-graph Cedar enforcement fires correctly
    on a POST-created graph (engine-layer policy reinstalled via
    `Omnigraph::with_policy` inside the create flow).
  - `concurrent_post_graphs_distinct_ids_all_succeed` — 4 concurrent
    POSTs with distinct graph_ids all return 201. Caught a real
    race in `rewrite_atomic` (see below).

Race fix — `rewrite_atomic_with_modify`:

The first composite test surfaced a real bug. The old
`rewrite_atomic(path, new_config, expected_hash)` captured the
baseline hash OUTSIDE the flock, then called rewrite_atomic which
re-acquired it inside. Under concurrent writers:

  - POST A: captures baseline H0, calls rewrite_atomic.
  - POST B: captures baseline H0 too (before A's update lands).
  - A: acquires flock, on-disk == H0, writes H1, releases.
  - A: updates baseline H0 → H1.
  - B: tries to acquire flock — waits.
  - B: acquires flock. On-disk is now H1. Expected (captured
       before A finished) is H0. MISMATCH → spurious Drift error.

Worse: even if the timing happens to align, B's `updated` config
was constructed from BYTES read before the flock. B writes a config
that doesn't include A's new graph — silent data loss.

The fix: new `config::rewrite_atomic_with_modify(path, baseline,
modify)` takes a closure. Inside the flock + baseline mutex:
  1. Read on-disk bytes, hash, compare to baseline.
  2. Parse on-disk YAML.
  3. Call `modify(parsed)` to produce the new config — receives
     fresh on-disk state, returns the modification.
  4. Serialize + write + fsync + rename + update baseline.

Everything is read-modify-write under the same critical section.
Concurrent writers serialize cleanly. Test confirmed this is no
longer a race.

The old `rewrite_atomic(path, new_config, expected_hash)` API stays
for tests that don't need the read-modify-write shape; the POST
handler switches to the new shape.

Version bump v0.6.0 → v0.7.0:
  - All 5 `crates/*/Cargo.toml` (compiler, engine, policy, cli, server)
    plus their inter-crate `path` dep version constraints.
  - `Cargo.lock` regenerated by `cargo build --workspace`.
  - `AGENTS.md` "Version surveyed" line, capability matrix HTTP-server
    row updated to mention multi-graph + cluster routes + atomic YAML
    rewrite.
  - `openapi.json` regenerated.

Docs:
  - `docs/releases/v0.7.0.md` (new) — release notes with breaking
    changes, new features, deferred items (DELETE, `delete_prefix`,
    actor forwarding), and the single→multi migration recipe.
  - `docs/user/server.md` — substantial section additions for the
    two modes, mode inference, cluster endpoint table, management
    endpoints, `omnigraph.yaml` ownership contract, `POST /graphs`
    body shape + status codes.
  - `docs/user/cli.md` — `omnigraph graphs list/create` section,
    deferred-DELETE note.
  - `docs/user/policy.md` — server-scoped Cedar actions
    (`graph_create`, `graph_list`), per-graph vs server-level policy
    composition, example server-level policy.

Workspace test pass: 573 tests green across all crates. Zero
failures. MR-731 spoof regression still pinned and passing across
the entire 10-PR series.

This commit closes MR-668. v0.7.0 is ready for tagging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:32:49 +02:00

8.3 KiB

Authorization (Cedar policy)

OmniGraph integrates AWS Cedar (cedar-policy = 4.9) for ABAC.

Policy actions

Per-graph actions (bind to Omnigraph::Graph::"<graph_id>"):

  1. read — query / snapshot / list branches & commits
  2. export — NDJSON export
  3. change — mutations
  4. schema_apply — apply schema migrations
  5. branch_create
  6. branch_delete
  7. branch_merge
  8. admin — reserved for policy-management surfaces (hot reload, audit log, approvals). No call site today; see MR-724 for the reservation rationale.

Server-scoped actions (v0.7.0+; bind to Omnigraph::Server::"root"):

  1. graph_createPOST /graphs runtime graph creation (multi-graph mode)
  2. graph_listGET /graphs registry enumeration (multi-graph mode)

Server-scoped actions cannot use branch_scope or target_branch_scope — they operate on the registry, not on a graph's branches. A rule cannot mix server-scoped and per-graph actions; split into separate rules. (graph_delete is reserved but not shipped in v0.7.0.)

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 are any | protected | unprotected

Per-graph vs. server-level policy (multi-graph mode)

In multi mode (omnigraph.yaml with a non-empty graphs: map), policy files attach at two levels:

server:
  policy:
    file: ./server-policy.yaml          # server-level: graph_create, graph_list

graphs:
  alpha:
    uri: s3://tenant-bucket/alpha
    policy:
      file: ./policies/alpha.yaml       # per-graph: read, change, branch_*, schema_apply
  beta:
    uri: s3://tenant-bucket/beta
    # no per-graph policy → no engine-layer Cedar enforcement on beta

Each graph's HTTP request flows through its own per-graph policy. Management endpoints (/graphs) flow through the server-level policy. When server.policy.file is unset and bearer tokens are configured, GET /graphs falls through to MR-723 default-deny (only read-equivalent actions allowed for authenticated actors — and graph_list is not read) → 403. So the operator must explicitly authorize via server-policy.yaml to expose /graphs.

Example server-level policy:

version: 1
groups:
  admins: [act-andrew]
rules:
  - id: admins-can-create-and-list-graphs
    allow:
      actors: { group: admins }
      actions: [graph_create, graph_list]

Configuration

omnigraph.yaml:

policy:
  file: ./policy.yaml          # Cedar rules + groups
  tests: ./policy.tests.yaml   # declarative test cases

cli:
  actor: act-andrew            # default actor for CLI direct-engine writes

Each rule must use exactly one of branch_scope or target_branch_scope.

cli.actor is the default actor identity for CLI direct-engine writes when policy.file is configured. Override per-invocation with --as <ACTOR> (top-level flag) — --as wins, otherwise cli.actor is used, otherwise no actor. With policy configured and neither set, the engine-layer footgun guard intentionally denies the write (silent bypass via "I forgot the actor" is exactly what the guard prevents). Remote HTTP writes ignore both — they resolve their actor server-side from the bearer token.

CLI

  • omnigraph policy validate — parse + count actors, exit 1 on parse error.
  • omnigraph policy test — run cases in policy.tests.yaml, exit 1 on any expectation mismatch.
  • omnigraph policy explain --actor … --action … [--branch …] [--target-branch …] — show decision and matched rule.
  • omnigraph --as <ACTOR> <subcommand> — set the actor for the duration of one invocation. Effective for change, load, ingest, branch create|delete|merge, and schema apply against local URIs. No-op against remote HTTP URIs (actor is bearer-token-resolved server-side).

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 runtime states (MR-723)

The HTTP server classifies its startup configuration into one of three states based on whether bearer tokens are configured and whether a policy file is set. The state determines what happens to a request that reaches authorize_request() without a matching policy permit.

State Tokens Policy file Behavior
Open no no Every request is permitted. Refuses to start unless --unauthenticated or OMNIGRAPH_UNAUTHENTICATED=1 is set — the operator must explicitly opt in.
DefaultDeny yes no Every authenticated request for an action other than read is rejected with HTTP 403. Closes the "tokens but forgot the policy file" trap — an operator who sets up auth and forgot to point at a policy file used to ship the illusion of protection.
PolicyEnabled any yes Every request is evaluated by Cedar against the configured policy.

The classifier is classify_server_runtime_state in crates/omnigraph-server/src/lib.rs; it returns Err for the "no tokens, no policy, no flag" cell so the server refuses to start instead of silently shipping an open instance. Tests pin every cell of the matrix and the State-2 deny path.

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.