mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
* feat(cli): --server accepts a literal URL (RFC-011 Decision 2) `resolve_server_flag` now treats a `--server` value containing `://` as a literal base URL (trailing slash trimmed; `--graph` appends `/graphs/<id>`), bypassing the operator-config `servers:` registry; a bare name still resolves through the registry. This is the replacement the upcoming `--uri http(s)://` deprecation points at, and a small ergonomic win on its own (`--server https://host` with no config entry). Token resolution for a literal-URL server falls to the legacy OMNIGRAPH_BEARER_TOKEN chain, same as a positional URL today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cli): address the parity-matrix arms with global --store/--server flags Prep for removing the positional-http→remote dispatch. The parity harness addressed both arms with a positional graph right after the verb (`omnigraph <verb> <addr> <args…>`), which only parses for top-level verbs — for nested subcommands (`schema show`, `branch list`, …) the address landed in the subcommand slot and BOTH arms failed identically, so the test passed vacuously (matching exit codes, never comparing output). Address both arms with the global flags instead — local `--store <graph>` (embedded), remote `--server <url>` (served) — appended after the verb + args, valid regardless of nesting. The previously-vacuous nested-verb parity checks now actually compare embedded vs remote (and pass — parity holds), and the remote arm no longer relies on the positional-URL dispatch that's about to be removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: --as on a served write is a hard error (was a silent no-op) A served write resolves the actor server-side from the bearer token, so `--as` could never set identity there — it was silently ignored. It now errors (in the remote write factory, before any HTTP call), pointing the user at removing `--as` or writing directly with `--store`. Reads don't carry `--as`, so this is write-path only. BREAKING for any script that passed `--as` to a remote write (it was a no-op, so behavior is unchanged except the now-explicit error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: a positional/--uri http(s):// URL no longer dispatches to a server Remote graphs must be addressed with `--server <url>` (or a named server / a profile binding one). A positional or `--uri` `http(s)://` URL on a data verb now errors instead of silently routing to the remote HTTP client — the scheme no longer carries transport semantics. The discriminator is `via_server`: a remote URL produced by a server scope is fine; a remote URL from a positional/`--uri` source is rejected (`reject_positional_remote` in both GraphClient factories). Storage verbs are unaffected — they already reject remote URIs through `resolve_local_graph` with the existing "direct (storage-native)" error. Migrated the gh-host keyed-credential system test to `--server <url>` (the literal URL still prefix-matches the operator server for token resolution). BREAKING: scripts addressing a server by a bare URL must switch to `--server <url>`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: remove the --target flag (use --store / --profile / --server) Removes the legacy named-graph flag and threads its parameter out of the whole resolver chain. `--target` resolved a graph name through `omnigraph.yaml`'s `graphs:` map; its replacements (`--store <uri>`, `--profile <name>`, `--server <name>`) all ship. - Drops the 22 `target` clap fields + the `--cluster` exclusion that named it. - Threads `target`/`cli_target` out of `resolve_uri`/`resolve_cli_graph`/ `resolve_local_graph`/`resolve_local_uri`/`resolve_storage_uri`/ `resolve_remote_bearer_token`/`apply_server_flag`/`execute_query_lint`/ `resolve_selected_graph`/`resolve_registry_selection_for_list`/ `execute_queries_{validate,list}`, the two `GraphClient` factories, and `ScopeFlags`/`ResolvedScope`. - Keeps the shared `OmnigraphConfig::resolve_target_uri` 3-arg (server boot uses it); the CLI passes None for the explicit-target arm. The `cli.graph` default (omnigraph.yaml bare-command fallback) is unchanged — its removal belongs to the omnigraph.yaml excision. - Operator/file aliases that bind a `graph` name still work: the name is now resolved to a URI inline (a positional URI wins). - Error messages and `--graph`/`--server`/`--store` help text no longer name `--target`; the queries-list selection hint points at `cli.graph`. BREAKING. Tests updated (named-target resolution rewritten onto `cli.graph`; positional-URI tests unchanged). Full omnigraph-cli suite green (228). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cli): drop --target and positional-http addressing; --as-on-served is an error Update the user docs for the legacy data-plane addressing removals: - the CLI `--target` flag is gone — address graphs with a positional URI, `--store`, `--profile`, or `--server <name|url>`; - a positional `http(s)://` URI no longer dispatches to a server (use `--server`); - `--as` on a served write is now rejected (was a silent no-op). Touches cli/reference.md (addressing intro, capability table, error examples, scopes), cli/index.md (the remote-read example → --server), operations/maintenance + policy, and the cluster docs' data-plane load guidance. The server's own `--target` boot flag is unchanged (server.md untouched). Also fixes a pre-existing broken maintenance link in search/indexes.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): --store is loudly exclusive with a positional URI / --server; test graphs→Served Address two Greptile findings on the RFC-011 slices: - Slice A (P1): `--store` combined with a positional URI silently dropped the URI (`scope.rs` did `store.or(uri)`); `--store` + `--server` errored with a misleading "positional URI" message. Now both combinations fail loudly with a declared `--store is exclusive with a positional URI and --server` error. - Slice B (P2): the `command_capability` unit test never exercised the one Data→Served refinement (`graphs`); added the assertion so deleting that guard can't pass silently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
9.7 KiB
Markdown
171 lines
9.7 KiB
Markdown
# 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.
|
|
9. `invoke_query` — gates invoking a server-side stored query (the `queries:` registry). Graph-scoped (like `admin`) — per-branch access is enforced by the inner `read` / `change` gate, so a rule that sets `branch_scope` on `invoke_query` is rejected. Coarse in this release: an `invoke_query` allow rule permits any stored query on the graph; a future, additive refinement adds an optional per-query-name scope without changing rules written against the coarse action. Enforced at `POST /queries/{name}` (see [server](server.md)). A stored *mutation* is double-gated: `invoke_query` to reach the tool, plus `change` for the write itself (the engine `_as` writers still enforce per the query body).
|
|
|
|
Server-scoped action (v0.6.0+; binds to `Omnigraph::Server::"root"`):
|
|
|
|
10. `graph_list` — `GET /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. (Runtime `graph_create` / `graph_delete` are reserved but not shipped in v0.6.0; operators add/remove graphs by editing `omnigraph.yaml` and restarting.)
|
|
|
|
## 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:
|
|
|
|
```yaml
|
|
server:
|
|
policy:
|
|
file: server-policy.yaml # server-level: 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
|
|
```
|
|
|
|
**Config follows graph identity, not server mode.** A graph served by **name**
|
|
(`--target <name>` or `server.graph`) uses its own `graphs.<name>.policy.file`,
|
|
exactly as in multi-graph mode. Top-level `policy.file` applies only to an
|
|
**anonymous** graph — one served by a bare `<URI>` with no `graphs:` entry.
|
|
Serving a **named** graph (single- or multi-graph mode) while top-level
|
|
`policy.file` (or `queries:`) is populated **refuses boot**, naming the block,
|
|
since the top-level value would otherwise be silently shadowed by the per-graph
|
|
block. Move per-graph rules to `graphs.<graph_id>.policy.file` and `graph_list`
|
|
rules to `server.policy.file`.
|
|
|
|
Each graph's HTTP request flows through its own per-graph policy. The management endpoint (`GET /graphs`) flows through the server-level policy. When `server.policy.file` is unset, `GET /graphs` is denied in every runtime state, including `--unauthenticated`; with bearer tokens configured, it returns 403 after admission control because `graph_list` is not a `read`-equivalent action. The operator must explicitly authorize via `server-policy.yaml` to expose `/graphs`.
|
|
|
|
Example server-level policy:
|
|
|
|
```yaml
|
|
version: 1
|
|
groups:
|
|
admins: [act-andrew]
|
|
rules:
|
|
- id: admins-can-list-graphs
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [graph_list]
|
|
```
|
|
|
|
## Configuration
|
|
|
|
`omnigraph.yaml`:
|
|
|
|
```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 per-graph rule may use at most one of `branch_scope` or `target_branch_scope`. Server-scoped rules (`graph_list`) take neither — they have no branch context.
|
|
|
|
`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
|
|
|
|
Policy tooling resolves its graph like server single-mode policy: `cli.graph`
|
|
wins, otherwise `server.graph` is used, otherwise the top-level `policy.file`
|
|
is validated/tested/explained as the anonymous policy.
|
|
|
|
- `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` (and its deprecated `ingest` alias), `branch create|delete|merge`, and `schema apply` against a direct (`--store`) graph. **Rejected** on a served write (`--server`): the actor is bearer-token-resolved server-side, so `--as` can't set it there.
|
|
|
|
## Enforcement
|
|
|
|
Policy is a property of the **engine**, not the transport. Every mutating
|
|
write — `mutate_as`, `load_as` (the deprecated `ingest_as` shims route
|
|
through it), `apply_schema_as`,
|
|
`branch_create_as`, `branch_create_from_as`, `branch_delete_as`,
|
|
`branch_merge_as` — consults the policy gate 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 policy 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
|
|
|
|
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 the authorization gate 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** | yes | yes | Authenticated requests that reach a configured policy engine are evaluated by Cedar. Server-scoped actions still require `server.policy.file`. |
|
|
|
|
The server refuses to start for the "no tokens, no policy, no flag" cell
|
|
and for "policy file, no tokens" — instead of silently shipping an open
|
|
instance or a policy-protected server that can only 401.
|
|
|
|
Server-side, request authorization 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 the HTTP boundary's authorization. Both layers consult the same
|
|
Cedar policy, 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? | The policy gate 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 the query plan. **Not yet implemented.** |
|
|
|
|
The engine-layer gate keeps its resource scope deliberately at branch
|
|
granularity (graph, branch, target branch, branch transition).
|
|
Per-type and per-row authority is the query-layer's job; conflating them
|
|
in the engine-layer scope 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 at the server's auth boundary: 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.
|