omnigraph/crates/omnigraph/tests/policy_engine_chassis.rs
aaltshuler e676c151bb feat(engine): unify load/ingest — load_as gains an optional fork base
load_as/load_file_as gain a base: Option<&str> parameter: with Some(base) a
missing target branch is forked from base first (the former ingest
semantics); with None the target branch must exist — staging fails on an
unknown branch, so a typo'd name can never create one. LoadResult gains
branch/base_branch/branch_created metadata (additive).

The ingest family (ingest, ingest_as, ingest_file, ingest_file_as) becomes
#[deprecated] shims over load_as that preserve the historical contract
exactly (from: None still means fork from main; base recorded even when no
fork happened). IngestResult and to_ingest_tables stay for the shims and
the server until the removal release.

The layered policy check is unchanged: Change on the target branch always,
BranchCreate additionally when a fork actually happens (enforced inside
branch_create_from_as with the actor threaded through).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 03:53:22 +03:00

429 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Engine-layer policy enforcement (MR-722 chassis core, PR #2 + PR #3).
//!
//! 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.
//!
//! 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, ReadTarget, SchemaApplyOptions};
use omnigraph::error::OmniError;
use omnigraph::loader::LoadMode;
use omnigraph_policy::{PolicyChecker, PolicyEngine};
use helpers::*;
/// 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:
writers: [act-allowed]
readers: [act-denied]
protected_branches: [main]
rules:
- id: writers-data
allow:
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
"#;
fn additive_schema() -> String {
helpers::TEST_SCHEMA.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
)
}
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_graph(&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();
let (db, _engine) = init_with_policy(&dir).await;
let desired = additive_schema();
let result = db
.apply_schema_as(&desired, SchemaApplyOptions::default(), Some("act-denied"))
.await;
match result {
Err(OmniError::Policy(msg)) => {
assert!(
msg.contains("denied"),
"expected denial message, got: {msg}"
);
}
Err(other) => panic!("expected OmniError::Policy, got: {other:?}"),
Ok(_) => panic!("expected denial — act-denied should not be able to SchemaApply"),
}
}
#[tokio::test]
async fn apply_schema_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let desired = additive_schema();
let result = db
.apply_schema_as(&desired, SchemaApplyOptions::default(), Some("act-allowed"))
.await
.expect("act-allowed should be able to SchemaApply");
assert!(result.applied);
}
#[tokio::test]
async fn apply_schema_without_actor_when_policy_is_installed_denies() {
// MR-722 footgun guard: if a PolicyChecker is installed AND the
// call site forgets to pass an actor, enforce() fails hard. Silent
// bypass via "I forgot the actor" is exactly what the gate is
// here to prevent.
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let desired = additive_schema();
// `apply_schema(...)` is the no-actor variant — delegates to
// apply_schema_as with actor=None.
let result = db.apply_schema(&desired).await;
match result {
Err(OmniError::Policy(msg)) => {
assert!(
msg.contains("no actor"),
"expected 'no actor' message, got: {msg}"
);
}
Err(other) => panic!("expected OmniError::Policy('no actor ...'), got: {other:?}"),
Ok(_) => panic!("expected denial — policy is installed but no actor was threaded"),
}
}
#[tokio::test]
async fn apply_schema_without_policy_still_works() {
// Baseline: when no policy is installed (the embedded/dev default),
// apply_schema and apply_schema_as both work regardless of whether
// an actor is passed. The enforce() gate is a strict no-op in this
// shape — proves PR #2 doesn't regress the no-policy path.
let dir = tempfile::tempdir().unwrap();
let db = init_and_load(&dir).await;
let desired = additive_schema();
// No-actor variant.
db.apply_schema(&desired)
.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",
None,
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",
None,
ONE_PERSON_JSONL,
LoadMode::Merge,
Some("act-allowed"),
)
.await
.expect("act-allowed should be able to load on main");
}
#[tokio::test]
async fn load_file_as_denies_when_policy_rejects_actor() {
// `load_file_as` was added in PR #104 as the actor-aware mirror of
// `load_file`, used by the CLI's `omnigraph load`. Tested
// indirectly via CLI integration; this test closes the direct-SDK
// gap so a regression in the file-read path doesn't ride through
// unnoticed.
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let data_path = dir.path().join("one-person.jsonl");
fs::write(&data_path, ONE_PERSON_JSONL).unwrap();
let result = db
.load_file_as(
"main",
None,
data_path.to_str().unwrap(),
LoadMode::Merge,
Some("act-denied"),
)
.await;
assert_denied(result, "load_file_as");
}
#[tokio::test]
async fn load_file_as_allows_when_policy_permits_actor() {
let dir = tempfile::tempdir().unwrap();
let (db, _engine) = init_with_policy(&dir).await;
let data_path = dir.path().join("one-person.jsonl");
fs::write(&data_path, ONE_PERSON_JSONL).unwrap();
db.load_file_as(
"main",
None,
data_path.to_str().unwrap(),
LoadMode::Merge,
Some("act-allowed"),
)
.await
.expect("act-allowed should be able to load_file_as on main");
}
#[tokio::test]
#[allow(deprecated)]
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]
#[allow(deprecated)]
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");
}