omnigraph/docs/user/policy.md
Andrew Altshuler a275306a15
policy: CLI policy injection — local writes go through engine enforce (MR-722) (#104)
Closes the CLI side of the policy chassis fan-out. Before this commit,
CLI direct-engine writes bypassed Cedar entirely because the CLI never
called `Omnigraph::with_policy(...)` for non-`policy validate|test|explain`
subcommands. After this commit, every CLI direct-engine writer
(change, load, ingest, branch create/delete/merge, schema apply) opens
the engine via a new `open_local_db_with_policy(uri, &config)` helper
that installs the configured `PolicyEngine` when `policy.file` is set,
and threads the resolved actor through to the `_as` writer methods.

Actor identity resolution:

- New top-level `--as <ACTOR>` global flag on the CLI overrides config.
- New `cli.actor` field in `omnigraph.yaml` provides a default actor.
- Precedence: `--as` > `cli.actor` > None.
- When policy is configured and neither is set, the engine-layer
  footgun guard fires and the write is denied — silent bypass via
  "I forgot the actor" is exactly what the guard prevents.
- Remote HTTP writes ignore both — bearer-token-resolved server-side.

Helpers added in main.rs:

- `open_local_db_with_policy(uri, &config) -> Result<Omnigraph>` —
  opens the DB and installs the PolicyEngine when configured. Without
  policy this is identical to a bare `Omnigraph::open`.
- `resolve_cli_actor(cli_as, &config) -> Option<&str>` — implements
  the flag > config > None precedence.

Engine: added `load_file_as` to the loader as the actor-aware mirror of
`load_file`, so CLI file-path loads flow through the same enforce gate
as in-memory `load_as` calls.

Test rewrite: `local_cli_policy_tooling_is_end_to_end_while_local_writes_stay_unenforced`
was the explicit assertion of the pre-chassis hole. Renamed and split:

- `local_cli_policy_tooling_is_end_to_end` — sanity for the read-only
  policy CLI surfaces (validate/test/explain), unchanged behavior.
- `local_cli_change_enforces_engine_layer_policy` — the new assertion:
  policy installed + no actor → footgun-guard denial; `--as act-bruno`
  on protected main → Cedar denial; `--as act-ragnor` (admins-write
  rule) on main → permit, write committed.

POLICY_E2E_YAML gains an `admins-write` rule so the permit case has
a non-trivial actor to exercise.

docs/user/policy.md updated with `cli.actor` + `--as <ACTOR>` usage.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:06:21 +03:00

5.3 KiB

Authorization (Cedar policy)

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

Policy actions

  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.

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

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-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.