policy: chassis fan-out — _as variants on the remaining 6 writers (MR-722) (#103)

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>
This commit is contained in:
Andrew Altshuler 2026-05-18 03:38:18 +03:00 committed by GitHub
parent 9973683261
commit da42beec41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 437 additions and 32 deletions

View file

@ -33,7 +33,7 @@ OmniGraph is a typed property-graph engine built as a coordination layer over ma
- **Multi-modal querying**: vector ANN (`nearest`), full-text (`search`/`fuzzy`/`match_text`/`bm25`), Reciprocal Rank Fusion (`rrf`), and graph traversal (`Expand`, anti-join `not { … }`) in one runtime.
- **Branches and commits across the whole graph**: Git-style — every successful publish appends to a commit DAG; merges are three-way at the row level.
- **Atomic per-query writes**: `mutate_as` and `load` accumulate insert/update batches into an in-memory `MutationStaging.pending` per touched table; one `stage_*` + `commit_staged` per table runs at end-of-query, then `ManifestBatchPublisher::publish` commits the manifest atomically with per-table `expected_table_versions` CAS. A mid-query failure leaves Lance HEAD untouched on staged tables — no drift, no run state machine, no staging branches. Deletes still inline-commit; D₂ at parse time prevents inserts/updates and deletes from coexisting in one query.
- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager), Cedar policy gating.
- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate.
- **CLI** driven by a single `omnigraph.yaml`; multi-format output (json/jsonl/csv/kv/table).
Throughout the docs, capabilities are split into **L1 — Inherited from Lance** vs **L2 — Added by OmniGraph**.
@ -226,8 +226,8 @@ omnigraph policy explain --actor act-alice --action change --branch main
| Per-query atomic writes | — | In-memory `MutationStaging.pending` accumulator + `stage_*` / `commit_staged` per touched table at end-of-query + publisher CAS via `commit_with_expected` (single manifest commit per `mutate_as` / `load`); D₂ parse-time rule keeps inserts/updates and deletes from mixing |
| Three-way row-level merge | — | `OrderedTableCursor` + `StagedTableWriter`, structured `MergeConflictKind` |
| Change feeds | — | `diff_between` / `diff_commits` with manifest fast path + ID streaming |
| Cedar policy | — | 8 actions, branch / target_branch / protected scopes, validate/test/explain CLI |
| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), policy gating, NDJSON streaming export |
| Cedar policy | — | 8 actions, branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as`, `ingest_as`, `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. |
| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export |
| CLI with config | — | `omnigraph.yaml`, aliases, multi-format output (json/jsonl/csv/kv/table) |
| Audit / actor tracking | — | `_as` write APIs + actor map in commit graph |
| Local RustFS bootstrap | — | `scripts/local-rustfs-bootstrap.sh` one-shot S3-backed dev environment |

View file

@ -1298,9 +1298,13 @@ async fn server_branch_create(
.map_err(ApiError::from_workload_reject)?;
{
let db = &state.engine;
db.branch_create_from(ReadTarget::branch(&from), &request.name)
.await
.map_err(ApiError::from_omni)?;
db.branch_create_from_as(
ReadTarget::branch(&from),
&request.name,
actor.as_ref().map(|Extension(a)| a.as_str()),
)
.await
.map_err(ApiError::from_omni)?;
}
Ok(Json(BranchCreateOutput {
uri: state.uri().to_string(),
@ -1359,7 +1363,7 @@ async fn server_branch_delete(
.map_err(ApiError::from_workload_reject)?;
{
let db = &state.engine;
db.branch_delete(&branch)
db.branch_delete_as(&branch, actor_id)
.await
.map_err(ApiError::from_omni)?;
}

View file

@ -991,6 +991,22 @@ impl Omnigraph {
}
pub async fn branch_create(&self, name: &str) -> Result<()> {
self.branch_create_as(name, None).await
}
/// Create a branch from the coordinator's currently-open snapshot,
/// with an explicit actor for engine-layer policy enforcement
/// (MR-722 fan-out). Scope is `TargetBranch(name)` — symmetric with
/// `branch_delete_as`: the branch being acted upon is the target.
/// Cedar rules using `target_branch_scope: protected` therefore see
/// the new-branch name and can deny e.g. creating any branch named
/// `main` from a non-privileged actor.
pub async fn branch_create_as(&self, name: &str, actor: Option<&str>) -> Result<()> {
self.enforce(
omnigraph_policy::PolicyAction::BranchCreate,
&omnigraph_policy::ResourceScope::TargetBranch(name.to_string()),
actor,
)?;
self.ensure_schema_state_valid().await?;
self.ensure_schema_apply_idle("branch_create").await?;
ensure_public_branch_ref(name, "branch_create")?;
@ -1002,8 +1018,41 @@ impl Omnigraph {
from: impl Into<ReadTarget>,
name: &str,
) -> Result<()> {
self.branch_create_from_as(from, name, None).await
}
/// Create a branch from a specific source branch with an explicit
/// actor for engine-layer policy enforcement (MR-722 fan-out).
///
/// Scope is `BranchTransition { source, target }` — matches the
/// HTTP-layer convention at `server_branch_create`
/// (branch=Some(from), target_branch=Some(name)), so engine and
/// HTTP fire the same Cedar decision. Pinned-snapshot sources
/// (which aren't a branch ref) materialize as the sentinel
/// `<snapshot>` for the policy check; Cedar rules using
/// `branch_scope: any` still match, rules pinning a specific
/// source branch correctly do not.
pub async fn branch_create_from_as(
&self,
from: impl Into<ReadTarget>,
name: &str,
actor: Option<&str>,
) -> Result<()> {
let target = from.into();
let source_branch = match &target {
ReadTarget::Branch(b) => b.clone(),
_ => "<snapshot>".to_string(),
};
self.enforce(
omnigraph_policy::PolicyAction::BranchCreate,
&omnigraph_policy::ResourceScope::BranchTransition {
source: source_branch,
target: name.to_string(),
},
actor,
)?;
self.ensure_schema_apply_idle("branch_create_from").await?;
self.branch_create_from_impl(from, name, false).await
self.branch_create_from_impl(target, name, false).await
}
async fn branch_create_from_impl(
@ -1049,6 +1098,22 @@ impl Omnigraph {
}
pub async fn branch_delete(&self, name: &str) -> Result<()> {
self.branch_delete_as(name, None).await
}
/// Delete a branch with an explicit actor for engine-layer policy
/// enforcement (MR-722 fan-out). Scope is `TargetBranch(name)` —
/// matches the HTTP-layer convention at `server_branch_delete`
/// (branch=None, target_branch=Some(name)). Cedar rules using
/// `target_branch_scope: protected` therefore correctly gate
/// deletion of protected branches (e.g. deny BranchDelete against
/// `main`).
pub async fn branch_delete_as(&self, name: &str, actor: Option<&str>) -> Result<()> {
self.enforce(
omnigraph_policy::PolicyAction::BranchDelete,
&omnigraph_policy::ResourceScope::TargetBranch(name.to_string()),
actor,
)?;
self.ensure_schema_state_valid().await?;
self.ensure_schema_apply_idle("branch_delete").await?;
ensure_public_branch_ref(name, "branch_delete")?;

View file

@ -1062,6 +1062,21 @@ impl Omnigraph {
target: &str,
actor_id: Option<&str>,
) -> Result<MergeOutcome> {
// Engine-layer policy gate (MR-722 fan-out / PR #3). Scope is
// `BranchTransition { source, target }` — matches the HTTP-layer
// convention at `server_branch_merge` (branch=Some(source),
// target_branch=Some(target)). Cedar rules using
// `target_branch_scope: protected` therefore correctly gate
// merges INTO protected branches without forbidding the
// (symmetric) source-side reference.
self.enforce(
omnigraph_policy::PolicyAction::BranchMerge,
&omnigraph_policy::ResourceScope::BranchTransition {
source: source.to_string(),
target: target.to_string(),
},
actor_id,
)?;
self.ensure_schema_apply_idle("branch_merge").await?;
self.branch_merge_impl(source, target, actor_id).await
}

View file

@ -692,6 +692,16 @@ impl Omnigraph {
params: &ParamMap,
actor_id: Option<&str>,
) -> Result<MutationResult> {
// Engine-layer policy gate (MR-722 fan-out / PR #3). Scope is
// `Branch(branch)` to match the HTTP-layer convention at
// `server_change` (branch=Some(branch), target_branch=None). When no
// PolicyChecker is installed this is a no-op; with policy installed
// and actor=None this fails hard (forget-the-actor footgun guard).
self.enforce(
omnigraph_policy::PolicyAction::Change,
&omnigraph_policy::ResourceScope::Branch(branch.to_string()),
actor_id,
)?;
self.mutate_with_current_actor(branch, query_source, query_name, params, actor_id)
.await
}

View file

@ -90,6 +90,18 @@ impl Omnigraph {
mode: LoadMode,
actor_id: Option<&str>,
) -> Result<IngestResult> {
// Engine-layer policy gate (MR-722 fan-out / PR #3). Scope is
// `Branch(branch)` for the data-write portion. If ingest creates
// a new branch as a side-effect (target branch doesn't exist),
// the inner `branch_create_from_as` call below additionally
// checks `BranchCreate` — both authorities are genuinely needed
// for "ingest into a fresh branch", so the layered check is
// correct, not redundant.
self.enforce(
omnigraph_policy::PolicyAction::Change,
&omnigraph_policy::ResourceScope::Branch(branch.to_string()),
actor_id,
)?;
self.ingest_with_current_actor(branch, from, data, mode, actor_id)
.await
}
@ -135,8 +147,18 @@ impl Omnigraph {
.iter()
.any(|name| name == &target_branch);
if branch_created {
self.branch_create_from(crate::db::ReadTarget::branch(&base_branch), &target_branch)
.await?;
// Thread the actor through to the implicit BranchCreate so
// policy decisions match what an explicit `branch_create_from_as`
// call would see. Calling the no-actor variant here would
// bypass BranchCreate enforcement when policy is installed —
// the footgun guard catches that case too, but threading is
// the correct fix.
self.branch_create_from_as(
crate::db::ReadTarget::branch(&base_branch),
&target_branch,
actor_id,
)
.await?;
}
let result = self.load_as(&target_branch, data, mode, actor_id).await?;
@ -160,6 +182,17 @@ impl Omnigraph {
mode: LoadMode,
actor_id: Option<&str>,
) -> Result<LoadResult> {
// Engine-layer policy gate (MR-722 fan-out / PR #3). Scope is
// `Branch(branch)` to match the HTTP-layer Change convention.
// `ingest_as` also calls `load_as` after enforcing its own
// Change gate — that double-check is fine because both gates
// resolve to identical Cedar decisions for the same actor +
// branch (the second check is a structurally-correct no-op).
self.enforce(
omnigraph_policy::PolicyAction::Change,
&omnigraph_policy::ResourceScope::Branch(branch.to_string()),
actor_id,
)?;
self.ensure_schema_state_valid().await?;
// Reject internal `__run__*` / system-prefixed branches at the
// public write boundary. Direct-publish paths assert this

View file

@ -1,39 +1,59 @@
//! Engine-layer policy enforcement (MR-722 chassis core, PR #2).
//! Engine-layer policy enforcement (MR-722 chassis core, PR #2 + PR #3).
//!
//! These tests exercise `Omnigraph::with_policy()` + `apply_schema_as()`
//! These tests exercise `Omnigraph::with_policy()` + every `_as` writer
//! via the SDK directly — *no HTTP layer involved*. They're the proof
//! that engine-layer enforcement works for embedded callers and CLI
//! direct-engine writes, not just server requests.
//!
//! `apply_schema_as` is the only writer wired in PR #2; PR #3 fans the
//! `enforce()` call out to the other six (`mutate_as`, `load`,
//! `ingest_as`, `branch_create_from`, `branch_delete`, `branch_merge`).
//! PR #2 wired `apply_schema_as`. PR #3 fans the same `enforce()` call
//! out to the remaining six writers — `mutate_as`, `load_as`,
//! `ingest_as`, `branch_create_as` / `branch_create_from_as`,
//! `branch_delete_as`, `branch_merge_as`. Each writer pair below
//! covers allow + deny via the engine-side gate; the allow case proves
//! the enforce call is correctly scoped (i.e. doesn't reject a legit
//! actor), the deny case proves it actually denies an unauthorized
//! actor — and both together pin the action × scope shape to match the
//! HTTP-layer authorize_request convention so engine and HTTP fire the
//! same Cedar decision.
mod helpers;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use omnigraph::db::{Omnigraph, SchemaApplyOptions};
use omnigraph::db::{Omnigraph, ReadTarget, SchemaApplyOptions};
use omnigraph::loader::LoadMode;
use omnigraph::error::OmniError;
use omnigraph_policy::{PolicyChecker, PolicyEngine};
use helpers::*;
/// Cedar policy: `act-allowed` may SchemaApply; `act-denied` is in the
/// known-actors set (so Cedar evaluates the policy, doesn't reject as
/// unknown) but has no permit rule.
/// Cedar policy: `act-allowed` may do every write; `act-denied` is in
/// the known-actors set (so Cedar evaluates the policy and doesn't
/// reject as unknown) but has no permit rule and is therefore implicitly
/// denied for every action.
///
/// The rule split mirrors the per-action scope convention: Change uses
/// `branch_scope`; SchemaApply, BranchCreate, BranchDelete, BranchMerge
/// use `target_branch_scope` (see `PolicyAction::uses_branch_scope` and
/// `uses_target_branch_scope` in `omnigraph-policy`).
const POLICY_YAML: &str = r#"
version: 1
groups:
schema-writers: [act-allowed]
writers: [act-allowed]
readers: [act-denied]
protected_branches: [main]
rules:
- id: writers-schema-apply
- id: writers-data
allow:
actors: { group: schema-writers }
actions: [schema_apply]
actors: { group: writers }
actions: [change]
branch_scope: any
- id: writers-branches-schema
allow:
actors: { group: writers }
actions: [schema_apply, branch_create, branch_delete, branch_merge]
target_branch_scope: any
"#;
@ -41,16 +61,54 @@ fn additive_schema() -> String {
helpers::TEST_SCHEMA.replace(" age: I32?\n}", " age: I32?\n nickname: String?\n}")
}
async fn init_with_policy(dir: &tempfile::TempDir) -> (Omnigraph, Arc<PolicyEngine>) {
let db = init_and_load(dir).await;
let policy_path = dir.path().join("policy.yaml");
fn install_policy(db: Omnigraph, dir_path: &Path) -> (Omnigraph, Arc<PolicyEngine>) {
let policy_path = dir_path.join("policy.yaml");
fs::write(&policy_path, POLICY_YAML).unwrap();
let engine = PolicyEngine::load(&policy_path, dir.path().to_str().unwrap()).unwrap();
let engine = PolicyEngine::load(&policy_path, dir_path.to_str().unwrap()).unwrap();
let engine = Arc::new(engine);
let db = db.with_policy(Arc::clone(&engine) as Arc<dyn PolicyChecker>);
(db, engine)
}
async fn init_with_policy(dir: &tempfile::TempDir) -> (Omnigraph, Arc<PolicyEngine>) {
let db = init_and_load(dir).await;
install_policy(db, dir.path())
}
/// Variant for tests that need a pre-created feature branch (branch_delete /
/// branch_merge setup). Create the branch BEFORE wrapping with policy so the
/// setup itself doesn't need to satisfy BranchCreate.
async fn init_with_policy_and_feature_branch(
dir: &tempfile::TempDir,
branch: &str,
) -> (Omnigraph, Arc<PolicyEngine>) {
let db = init_and_load(dir).await;
db.branch_create_from(ReadTarget::branch("main"), branch)
.await
.expect("setup: create feature branch before installing policy");
install_policy(db, dir.path())
}
// `MUTATION_QUERIES` from helpers/mod.rs already defines `insert_person($name, $age)`
// — reuse it rather than redefining one here, so this test exercises the
// same surface the engine integration tests do.
/// One JSONL record for `load_as` / `ingest_as` exercises.
const ONE_PERSON_JSONL: &str = r#"{"type": "Person", "data": {"name": "Eve"}}"#;
fn assert_denied(result: Result<impl std::fmt::Debug, OmniError>, what: &str) {
match result {
Err(OmniError::Policy(msg)) => {
assert!(
msg.contains("denied"),
"{what}: expected denial message, got: {msg}"
);
}
Err(other) => panic!("{what}: expected OmniError::Policy, got: {other:?}"),
Ok(value) => panic!("{what}: expected denial, got Ok({value:?})"),
}
}
#[tokio::test]
async fn apply_schema_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
@ -127,3 +185,191 @@ async fn apply_schema_without_policy_still_works() {
.await
.expect("no policy → no enforcement → apply succeeds");
}
// ─── PR #3 writer fan-out ─────────────────────────────────────────────────
//
// One allow + one deny test per newly-wired writer. The allow case
// proves the enforce scope is correctly shaped (i.e. doesn't reject a
// legit actor whose policy permit matches the engine-side scope). The
// deny case proves the gate actually fires for an unauthorized actor.
// Footgun-guard (no-actor + policy-installed) is already proved by
// `apply_schema_without_actor_when_policy_is_installed_denies` and
// applies identically to every `_as` variant — duplicating it per
// writer would be redundant.
#[tokio::test]
async fn mutate_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let params = mixed_params(&[("$name", "Eve")], &[("$age", 22)]);
let result = db
.mutate_as(
"main",
MUTATION_QUERIES,
"insert_person",
&params,
Some("act-denied"),
)
.await;
assert_denied(result, "mutate_as");
}
#[tokio::test]
async fn mutate_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let params = mixed_params(&[("$name", "Eve")], &[("$age", 22)]);
db.mutate_as(
"main",
MUTATION_QUERIES,
"insert_person",
&params,
Some("act-allowed"),
)
.await
.expect("act-allowed should be able to Change on main");
}
#[tokio::test]
async fn load_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let result = db
.load_as("main", ONE_PERSON_JSONL, LoadMode::Merge, Some("act-denied"))
.await;
assert_denied(result, "load_as");
}
#[tokio::test]
async fn load_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
db.load_as(
"main",
ONE_PERSON_JSONL,
LoadMode::Merge,
Some("act-allowed"),
)
.await
.expect("act-allowed should be able to load on main");
}
#[tokio::test]
async fn ingest_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let result = db
.ingest_as(
"main",
Some("main"),
ONE_PERSON_JSONL,
LoadMode::Merge,
Some("act-denied"),
)
.await;
assert_denied(result, "ingest_as");
}
#[tokio::test]
async fn ingest_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
db.ingest_as(
"main",
Some("main"),
ONE_PERSON_JSONL,
LoadMode::Merge,
Some("act-allowed"),
)
.await
.expect("act-allowed should be able to ingest on main");
}
#[tokio::test]
async fn branch_create_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let result = db.branch_create_as("feature", Some("act-denied")).await;
assert_denied(result, "branch_create_as");
}
#[tokio::test]
async fn branch_create_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
db.branch_create_as("feature", Some("act-allowed"))
.await
.expect("act-allowed should be able to BranchCreate");
}
#[tokio::test]
async fn branch_create_from_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let result = db
.branch_create_from_as(ReadTarget::branch("main"), "feature", Some("act-denied"))
.await;
assert_denied(result, "branch_create_from_as");
}
#[tokio::test]
async fn branch_create_from_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
db.branch_create_from_as(ReadTarget::branch("main"), "feature", Some("act-allowed"))
.await
.expect("act-allowed should be able to BranchCreate from main");
}
#[tokio::test]
async fn branch_delete_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy_and_feature_branch(&dir, "feature").await;
let result = db.branch_delete_as("feature", Some("act-denied")).await;
assert_denied(result, "branch_delete_as");
}
#[tokio::test]
async fn branch_delete_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy_and_feature_branch(&dir, "feature").await;
db.branch_delete_as("feature", Some("act-allowed"))
.await
.expect("act-allowed should be able to BranchDelete");
}
#[tokio::test]
async fn branch_merge_as_denies_when_policy_rejects_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy_and_feature_branch(&dir, "feature").await;
let result = db
.branch_merge_as("feature", "main", Some("act-denied"))
.await;
assert_denied(result, "branch_merge_as");
}
#[tokio::test]
async fn branch_merge_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy_and_feature_branch(&dir, "feature").await;
// No diverging writes on feature → merge is a no-op fast-forward,
// but it still goes through enforce(BranchMerge, ...). That's the
// path under test; the actual merge outcome is incidental.
db.branch_merge_as("feature", "main", Some("act-allowed"))
.await
.expect("act-allowed should be able to BranchMerge");
}

View file

@ -11,9 +11,7 @@ OmniGraph integrates AWS Cedar (`cedar-policy = 4.9`) for ABAC.
5. `branch_create`
6. `branch_delete`
7. `branch_merge`
8. `run_publish`
9. `run_abort`
10. `admin` — reserved
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
@ -39,9 +37,43 @@ Each rule must use exactly one of `branch_scope` or `target_branch_scope`.
- `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.
## Server enforcement
## Enforcement
Every mutating endpoint calls `authorize_request()` *before* the handler runs; decisions are logged with actor / action / branch / outcome / matched rule.
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)