mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-15 01:55:13 +02:00
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>
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
//! 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",
|
||
¶ms,
|
||
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",
|
||
¶ms,
|
||
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");
|
||
}
|