diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 3d28cb0f8..376e069c4 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -23,7 +23,9 @@ control — manage or inspect a cluster (cluster via --config; policy & queries --cluster).\n \ local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\ MANAGED FOLDERS: cluster commands use .omnigraph/context; cluster token caches data access.\n\ -query and mutate require --graph and a cached data credential. --direct selects legacy addressing.\n\ +Implicit query and mutate use folder context and require --graph plus a cached data credential.\n\ +Explicit target selectors retain ordinary addressing; competing ambient targets refuse.\n\ +--direct selects ordinary addressing, including operator profiles and defaults.\n\ See the 'Command capabilities' section of the CLI reference for which flags apply where.")] pub(crate) struct Cli { /// Explicitly use legacy addressing and credentials, ignoring folder context. diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs index a86c28fe9..9038c3ae6 100644 --- a/crates/omnigraph-cli/src/managed/data.rs +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -166,7 +166,7 @@ fn scope(cli: &Cli) -> Result<()> { { return Err(Failure::refused( "managed_scope_conflict", - "managed data uses folder context and its cached credential; --server, --profile, --store, --cluster, and --as require explicit --direct", + "this managed operation uses its selected context; ordinary target selectors and an explicit --as are not applicable", )); } Ok(()) @@ -317,18 +317,48 @@ fn load( fn skips_context(cli: &Cli) -> bool { cli.direct - || matches!(cli.command, Command::Cluster { .. }) - || (crate::planes::command_plane(&cli.command) == crate::planes::Plane::Session - && !matches!(cli.command, Command::Alias { .. })) + || !matches!(cli.command, Command::Query { .. } | Command::Mutate { .. }) + || cli.server.is_some() + || cli.profile.is_some() + || cli.store.is_some() + || cli.cluster.is_some() +} + +/// This check never resolves a competing target or consults credentials. Even +/// an unknown profile or matching-looking server URL is ambiguous beside a +/// managed binding. Call it only after selecting a valid exact-directory +/// context, so ordinary commands retain their own operator-config behavior. +fn has_ambient_target() -> Result { + if std::env::var_os(crate::scope::PROFILE_ENV).is_some_and(|value| !value.is_empty()) { + return Ok(true); + } + let operator = crate::operator::load_operator_config().map_err(|_| { + Failure::refused( + "operator_config_invalid", + "cannot read valid operator configuration to exclude a competing data target", + ) + })?; + Ok(operator.default_server().is_some() || operator.default_store().is_some()) } -fn resolve(cli: &Cli, config: &std::path::Path, store: &impl Store) -> Result> { +fn resolve( + cli: &Cli, + config: &std::path::Path, + store: &impl Store, + ambient_target: impl FnOnce() -> Result, +) -> Result> { if skips_context(cli) { return Ok(None); } let Some(context) = super::read_context(config)? else { return Ok(None); }; + if ambient_target()? { + return Err(Failure::refused( + "managed_target_ambiguous", + "folder context competes with OMNIGRAPH_PROFILE or an operator default target; select the intended ordinary target explicitly, use --direct for ordinary ambient resolution, or clear the competing ambient target to use this managed folder", + )); + } let (action, named) = match &cli.command { Command::Query { query, @@ -340,12 +370,7 @@ fn resolve(cli: &Cli, config: &std::path::Path, store: &impl Store) -> Result ("change", query.is_none() && query_string.is_none()), - _ => { - return Err(Failure::refused( - "managed_command_unsupported", - "managed data currently supports query and mutate; use --direct only when legacy addressing is intended", - )); - } + _ => unreachable!("only implicit query/mutate consult data context"), }; scope(cli)?; let graph = cli @@ -374,7 +399,7 @@ pub(crate) fn client(cli: &Cli) -> std::result::Result, Outp }; let result = std::env::current_dir() .map_err(|_| Failure::refused("context_invalid", "cannot resolve the current directory")) - .and_then(|cwd| resolve(cli, &cwd, &auth::DATA_STORE)); + .and_then(|cwd| resolve(cli, &cwd, &auth::DATA_STORE, has_ambient_target)); result.map_err(|e| Output::from_result(Err(e), json, 2)) } diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs index 59c63996a..86ef5079a 100644 --- a/crates/omnigraph-cli/src/managed/data/tests.rs +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -355,28 +355,19 @@ async fn invalid_issuance_never_replaces_cached_authority() { } #[test] -fn managed_routing_refuses_scope_overrides_and_other_data_verbs_before_keychain() { +fn managed_routing_preserves_selected_managed_authority_without_fallback() { let dir = tempfile::tempdir().unwrap(); let context = context(); super::super::save_context(dir.path(), &context).unwrap(); let store = MemoryStore::default(); for args in [ vec!["query", "q"], - vec![ - "query", - "q", - "--graph", - "knowledge", - "--server", - "https://foreign.example", - ], vec!["mutate", "m", "--graph", "knowledge", "--as", "fake"], - vec!["snapshot", "--store", "/tmp/never-open"], - vec!["alias", "dangerous"], - vec!["graphs", "list"], ] { let cli = Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).unwrap(); - let failure = resolve(&cli, dir.path(), &store).err().unwrap(); + let failure = resolve(&cli, dir.path(), &store, || Ok(false)) + .err() + .unwrap(); assert_ne!(failure.body["type"], "data_credential_required"); } let direct = Cli::try_parse_from([ @@ -388,27 +379,137 @@ fn managed_routing_refuses_scope_overrides_and_other_data_verbs_before_keychain( "https://legacy.example", ]) .unwrap(); - assert!(resolve(&direct, dir.path(), &store).unwrap().is_none()); + assert!( + resolve(&direct, dir.path(), &store, || panic!( + "explicit selection read operator defaults" + )) + .unwrap() + .is_none() + ); let child = dir.path().join("child"); std::fs::create_dir(&child).unwrap(); let query = Cli::try_parse_from(["omnigraph", "query", "q", "--graph", "knowledge"]).unwrap(); - assert!(resolve(&query, &child, &store).unwrap().is_none()); + assert!( + resolve(&query, &child, &store, || panic!( + "absent context read operator defaults" + )) + .unwrap() + .is_none() + ); assert_eq!( - resolve(&query, dir.path(), &store).err().unwrap().body["type"], + resolve(&query, dir.path(), &store, || Ok(false)) + .err() + .unwrap() + .body["type"], "data_credential_required" ); let cached = credential(&context, "https://data.example"); save(&store, &context, &cached); - assert!(resolve(&query, dir.path(), &store).unwrap().is_some()); + assert!( + resolve(&query, dir.path(), &store, || Ok(false)) + .unwrap() + .is_some() + ); let mut narrowed = cached; narrowed.grants[0].actions = vec!["read".into()]; save(&store, &context, &narrowed); assert_eq!( - resolve(&query, dir.path(), &store).err().unwrap().body["type"], + resolve(&query, dir.path(), &store, || Ok(false)) + .err() + .unwrap() + .body["type"], "data_scope_missing" ); } +struct NoCredentialAccess; + +impl Store for NoCredentialAccess { + fn get(&self, _: &str) -> Result> { + panic!("routing preflight accessed credentials") + } + fn put(&self, _: &str, _: &str) -> Result<()> { + panic!("routing preflight wrote credentials") + } + fn remove(&self, _: &str) -> Result<()> { + panic!("routing preflight removed credentials") + } +} + +#[test] +fn managed_data_issue_633_explicit_and_unrelated_commands_skip_context() { + let dir = tempfile::tempdir().unwrap(); + super::super::save_context(dir.path(), &context()).unwrap(); + // An unreadable-as-context object makes an accidental context read fail; + // the ambient callback and store also fail if either is consulted. + std::fs::remove_file(dir.path().join(".omnigraph/context")).unwrap(); + std::fs::create_dir(dir.path().join(".omnigraph/context")).unwrap(); + for args in [ + vec!["query", "q", "--server", "legacy"], + vec!["read", "q", "--profile", "legacy"], + vec!["mutate", "--store", "file:///scratch", "-e", "source"], + vec!["change", "m", "--cluster", "local"], + vec!["query", "q", "--direct"], + vec!["init", "--schema", "schema.pg", "file:///scratch"], + vec!["load", "--data", "data.jsonl", "--mode", "append"], + vec!["schema", "plan", "--schema", "schema.pg"], + vec!["commit", "list"], + vec!["graphs", "list"], + vec!["alias", "people"], + vec!["queries", "list"], + vec!["queries", "validate"], + vec!["lint", "--schema", "schema.pg", "--query", "q.gq"], + vec!["snapshot"], + vec!["branch", "list"], + vec!["cluster", "status"], + ] { + let cli = Cli::try_parse_from(std::iter::once("omnigraph").chain(args)).unwrap(); + assert!( + resolve(&cli, dir.path(), &NoCredentialAccess, || { + panic!("bypassed command read operator routing") + }) + .unwrap() + .is_none() + ); + } +} + +#[test] +fn managed_data_issue_633_ambiguity_and_invalid_context_precede_credentials() { + let dir = tempfile::tempdir().unwrap(); + super::super::save_context(dir.path(), &context()).unwrap(); + for verb in ["query", "read", "mutate", "change"] { + let cli = Cli::try_parse_from(["omnigraph", verb, "q", "--graph", "knowledge"]).unwrap(); + assert_eq!( + resolve(&cli, dir.path(), &NoCredentialAccess, || Ok(true)) + .err() + .unwrap() + .body["type"], + "managed_target_ambiguous" + ); + assert_eq!( + resolve(&cli, dir.path(), &NoCredentialAccess, || { + Err(Failure::refused("operator_config_invalid", "fixture")) + }) + .err() + .unwrap() + .body["type"], + "operator_config_invalid" + ); + } + std::fs::write(dir.path().join(".omnigraph/context"), "malformed").unwrap(); + let cli = Cli::try_parse_from(["omnigraph", "query", "q", "--graph", "knowledge"]).unwrap(); + assert_eq!( + resolve(&cli, dir.path(), &NoCredentialAccess, || { + panic!("invalid context consulted another target") + }) + .err() + .unwrap() + .body["type"], + "context_invalid" + ); +} + #[tokio::test] async fn managed_data_transport_refuses_redirect_and_bounds_body() { let target = IntentApiFixture::new(vec![]); diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index 8aa851c84..eb178351d 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -57,7 +57,7 @@ fn assert_no_core_effects(root: &std::path::Path) { } #[test] -fn managed_data_process_refuses_unsupported_and_conflicting_scope_before_keychain() { +fn managed_data_process_refuses_missing_graph_and_actor_override_before_keychain() { let temp = tempdir().unwrap(); let api = IntentApiFixture::new(vec![]); write_managed_context(temp.path(), &api.origin); @@ -72,15 +72,6 @@ fn managed_data_process_refuses_unsupported_and_conflicting_scope_before_keychai "forged", "--json", ], - vec![ - "query", - "q", - "--graph", - "knowledge", - "--server", - "https://foreign.example", - "--json", - ], vec!["cluster", "token", "--actions", "read", "--json"], vec![ "cluster", @@ -91,7 +82,12 @@ fn managed_data_process_refuses_unsupported_and_conflicting_scope_before_keychai "--json", ], ] { - let output = cli().current_dir(temp.path()).args(&args).output().unwrap(); + let output = cli() + .current_dir(temp.path()) + .env_remove("OMNIGRAPH_PROFILE") + .args(&args) + .output() + .unwrap(); assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); let problem = parse_stdout_json(&output); assert!( @@ -103,15 +99,6 @@ fn managed_data_process_refuses_unsupported_and_conflicting_scope_before_keychai ); assert_no_core_effects(temp.path()); } - for args in [ - vec!["graphs", "list"], - vec!["alias", "legacy-alias"], - vec!["snapshot", "--store", "/tmp/managed-must-not-open"], - ] { - let output = cli().current_dir(temp.path()).args(&args).output().unwrap(); - assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); - assert!(String::from_utf8_lossy(&output.stderr).contains("managed_command_unsupported")); - } assert!(api.requests().is_empty()); assert_no_core_effects(temp.path()); } @@ -154,6 +141,270 @@ fn managed_data_direct_override_uses_only_explicit_legacy_transport() { assert_no_core_effects(temp.path()); } +#[test] +fn managed_data_issue_633_explicit_targets_ignore_folder_context() { + for malformed in [false, true] { + for selector in ["--server", "--profile"] { + for verb in ["query", "mutate"] { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + if malformed { + fs::write(temp.path().join(".omnigraph/context"), "malformed").unwrap(); + } + let reply = if verb == "query" { + serde_json::json!({ + "query_name":"q", "target":{"branch":"main"}, "row_count":1, + "columns":["value"], "rows":[{"value":42}], "graph_commit_id":"head-a" + }) + } else { + serde_json::json!({ + "branch":"main", "query_name":"q", "affected_nodes":1, + "affected_edges":0, "actor_id":"legacy-actor", "commit":null + }) + }; + let data = IntentApiFixture::new(vec![IntentReply::json(200, reply)]); + let home = temp.path().join("operator"); + fs::create_dir(&home).unwrap(); + fs::write( + home.join("config.yaml"), + format!( + "servers:\n staging:\n url: {}\nprofiles:\n staging:\n server: staging\n default_graph: knowledge\n", + data.origin + ), + ) + .unwrap(); + let mut command = cli(); + command + .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", &home) + .env("OMNIGRAPH_PROFILE", "unused-unknown-profile") + .env("OMNIGRAPH_TOKEN_STAGING", "explicit-legacy-token") + .env("OMNIGRAPH_BEARER_TOKEN", "unused-legacy-fallback") + .env("OMNIGRAPH_CONTROL_API", &api.origin) + .env("OMNIGRAPH_CONTROL_TOKEN", "never-data") + .args([verb, "q", selector, "staging", "--json"]) + .timeout(std::time::Duration::from_secs(15)); + if selector == "--server" { + command.args(["--graph", "knowledge"]); + } + let output = output_success(&mut command); + let payload = parse_stdout_json(&output); + if verb == "query" { + assert_eq!(payload["rows"], serde_json::json!([{"value":42}])); + } else { + assert_eq!(payload["affected_nodes"], 1); + } + let requests = data.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/graphs/knowledge/queries/q"); + assert_eq!( + requests[0].headers["authorization"], + "Bearer explicit-legacy-token" + ); + assert!(api.requests().is_empty()); + data.assert_complete(); + assert_no_core_effects(temp.path()); + } + } + } +} + +#[test] +fn managed_data_issue_633_ambient_targets_refuse_without_selecting_either() { + for (config, profile) in [ + ("{}", Some("unknown-profile")), + ("defaults:\n server: staging\n", None), + ("defaults:\n store: file:///must-not-open\n", None), + ] { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + let home = temp.path().join("operator"); + fs::create_dir(&home).unwrap(); + fs::write(home.join("config.yaml"), config).unwrap(); + for verb in ["query", "mutate"] { + let mut command = cli(); + command + .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", &home) + .env_remove("OMNIGRAPH_PROFILE") + .env("OMNIGRAPH_BEARER_TOKEN", "must-not-be-used") + .args([verb, "q", "--json"]) + .timeout(std::time::Duration::from_secs(15)); + if let Some(profile) = profile { + command.env("OMNIGRAPH_PROFILE", profile); + } + // No graph means the old dispatcher fails without touching a real + // keychain too; the regression is which target decision occurs first. + let output = command.output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert_eq!( + parse_stdout_json(&output)["type"], + "managed_target_ambiguous" + ); + assert!(api.requests().is_empty()); + assert_no_core_effects(temp.path()); + } + } +} + +#[test] +fn managed_data_issue_633_folder_context_does_not_gate_local_graph_work() { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + fs::write(temp.path().join(".omnigraph/context"), "malformed").unwrap(); + let graph = graph_path(temp.path()); + let schema = fixture("test.pg"); + let queries = fixture("test.gq"); + let command = || { + let mut command = cli(); + command + .current_dir(temp.path()) + .env_remove("OMNIGRAPH_PROFILE") + .env_remove("OMNIGRAPH_BEARER_TOKEN") + .timeout(std::time::Duration::from_secs(30)); + command + }; + output_success( + command() + .arg("init") + .arg("--schema") + .arg(&schema) + .arg(&graph), + ); + assert!(graph.exists()); + output_success( + command() + .args(["load", "--mode", "append", "--data"]) + .arg(fixture("test.jsonl")) + .arg("--store") + .arg(&graph) + .arg("--json"), + ); + output_success( + command() + .args(["lint", "--schema"]) + .arg(&schema) + .arg("--query") + .arg(&queries) + .arg("--json"), + ); + let query = output_success( + command() + .args(["query", "get_person", "--query"]) + .arg(&queries) + .arg("--store") + .arg(&graph) + .args(["--params", r#"{"name":"Alice"}"#, "--json"]), + ); + assert_eq!(parse_stdout_json(&query)["rows"][0]["p.name"], "Alice"); + output_success( + command() + .args(["schema", "plan", "--schema"]) + .arg(&schema) + .arg("--store") + .arg(&graph) + .arg("--json"), + ); + output_success( + command() + .args(["commit", "list", "--store"]) + .arg(&graph) + .arg("--json"), + ); + assert!(api.requests().is_empty()); + assert_no_core_effects(temp.path()); +} + +#[test] +fn managed_data_issue_633_operator_preferences_do_not_select_a_target() { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + let home = temp.path().join("operator"); + fs::create_dir(&home).unwrap(); + fs::write( + home.join("config.yaml"), + "operator:\n actor: preferred-actor\ndefaults:\n output: json\n default_graph: knowledge\nprofiles:\n unused:\n server: staging\n", + ) + .unwrap(); + for profile in [None, Some("")] { + let mut command = cli(); + command + .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", &home) + .env_remove("OMNIGRAPH_PROFILE") + .args(["query", "q", "--json"]); + if let Some(profile) = profile { + command.env("OMNIGRAPH_PROFILE", profile); + } + let output = command.output().unwrap(); + assert_eq!(parse_stdout_json(&output)["type"], "graph_required"); + assert!(api.requests().is_empty()); + } + fs::write(home.join("config.yaml"), "defaults: [invalid]").unwrap(); + let output = cli() + .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", &home) + .env_remove("OMNIGRAPH_PROFILE") + .args(["query", "q", "--json"]) + .output() + .unwrap(); + assert_eq!( + parse_stdout_json(&output)["type"], + "operator_config_invalid" + ); + assert!(api.requests().is_empty()); + assert_no_core_effects(temp.path()); +} + +#[test] +fn managed_data_issue_633_direct_preserves_ambient_legacy_resolution() { + for use_profile in [false, true] { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + let data = IntentApiFixture::new(vec![IntentReply::json( + 200, + serde_json::json!({ + "query_name":"q", "target":{"branch":"main"}, "row_count":1, + "columns":["value"], "rows":[{"value":42}], "graph_commit_id":"head-a" + }), + )]); + let home = temp.path().join("operator"); + fs::create_dir(&home).unwrap(); + fs::write( + home.join("config.yaml"), + format!( + "servers:\n staging:\n url: {}\nprofiles:\n staging:\n server: staging\ndefaults:\n server: staging\n", + data.origin + ), + ) + .unwrap(); + let mut command = cli(); + command + .current_dir(temp.path()) + .env("OMNIGRAPH_HOME", &home) + .env_remove("OMNIGRAPH_PROFILE") + .env("OMNIGRAPH_TOKEN_STAGING", "legacy-ambient-token") + .args(["query", "q", "--graph", "knowledge", "--json", "--direct"]); + if use_profile { + command.env("OMNIGRAPH_PROFILE", "staging"); + } + let output = output_success(&mut command); + assert_eq!(parse_stdout_json(&output)["rows"][0]["value"], 42); + assert_eq!( + data.requests()[0].headers["authorization"], + "Bearer legacy-ambient-token" + ); + assert!(api.requests().is_empty()); + data.assert_complete(); + assert_no_core_effects(temp.path()); + } +} + #[test] fn managed_use_verifies_access_before_writing_context() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 51b3dd1c6..dd6f972ea 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -42,9 +42,10 @@ use diff::{ diff_resources, resource_kind, }; pub use serve::{ - ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_graph_ids, - cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, - resolve_graph_storage_uri, + RootBoundServingSnapshot, ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, + cluster_graph_ids, cluster_root_for_graph_uri, read_root_bound_serving_snapshot, + read_root_bound_serving_snapshot_from_storage, read_serving_snapshot, + read_serving_snapshot_from_storage, resolve_graph_storage_uri, }; use store::ClusterStore; use sweep::{ diff --git a/crates/omnigraph-cluster/src/serve.rs b/crates/omnigraph-cluster/src/serve.rs index 6a2c2a25b..ad36e7860 100644 --- a/crates/omnigraph-cluster/src/serve.rs +++ b/crates/omnigraph-cluster/src/serve.rs @@ -37,8 +37,6 @@ pub struct ServingPolicy { /// Everything a server needs to boot from the cluster catalog (RFC-005 §D2). #[derive(Debug, Clone)] pub struct ServingSnapshot { - /// Canonical root of the store used for this snapshot (RFC 0053). - pub canonical_root: String, pub graphs: Vec, pub queries: Vec, pub policies: Vec, @@ -57,6 +55,32 @@ pub struct ServingSnapshot { pub quarantined_graphs: Vec, } +/// A serving snapshot paired with the canonical root of the same opened store. +/// +/// The binding is read-only metadata for managed boot trust, not a writer fence +/// or a guarantee that the ledger has not changed since this snapshot was read. +/// Only the root-bound readers can construct this pair. +#[derive(Debug, Clone)] +pub struct RootBoundServingSnapshot { + snapshot: ServingSnapshot, + canonical_root: String, +} + +impl RootBoundServingSnapshot { + pub fn snapshot(&self) -> &ServingSnapshot { + &self.snapshot + } + + pub fn canonical_root(&self) -> &str { + &self.canonical_root + } + + /// Discard the root binding and return the legacy snapshot projection. + pub fn into_snapshot(self) -> ServingSnapshot { + self.snapshot + } +} + /// Read the applied revision as a serving snapshot — the read-only loader for /// the Phase-5 server boot. Cluster-global readiness failures are still /// all-or-nothing, but graph-attributed pending recovery sidecars quarantine @@ -67,10 +91,14 @@ pub struct ServingSnapshot { pub async fn read_serving_snapshot( config_dir: impl AsRef, ) -> Result> { - let config_dir = config_dir.as_ref().to_path_buf(); + let backend = store_for_serving_snapshot(config_dir.as_ref())?; + read_snapshot_with_store(&backend).await +} + +fn store_for_serving_snapshot(config_dir: &Path) -> Result> { // The declared storage: root decides where the ledger/catalog/graphs // live; config parse errors surface through the normal validation path. - let parsed = parse_cluster_config(&config_dir); + let parsed = parse_cluster_config(config_dir); let storage_root = parsed.raw.as_ref().and_then(|raw| { raw.storage .as_deref() @@ -83,9 +111,9 @@ pub async fn read_serving_snapshot( Ok(backend) => backend, Err(diagnostic) => return Err(vec![diagnostic]), }, - None => ClusterStore::for_config_dir(&config_dir), + None => ClusterStore::for_config_dir(config_dir), }; - read_snapshot_with_store(backend).await + Ok(backend) } /// Read the applied revision directly from a storage root URI — config-free @@ -98,7 +126,38 @@ pub async fn read_serving_snapshot_from_storage( ) -> Result> { let backend = ClusterStore::for_storage_root(storage_root).map_err(|diagnostic| vec![diagnostic])?; - read_snapshot_with_store(backend).await + read_snapshot_with_store(&backend).await +} + +/// Read an applied snapshot and its canonical store root for managed boot trust. +/// Ordinary snapshot reads do not perform this extra canonicalization step. +pub async fn read_root_bound_serving_snapshot( + config_dir: impl AsRef, +) -> Result> { + let backend = store_for_serving_snapshot(config_dir.as_ref())?; + read_root_bound_snapshot_with_store(&backend).await +} + +/// Read a root-bound applied snapshot directly from its storage URI. +pub async fn read_root_bound_serving_snapshot_from_storage( + storage_root: &str, +) -> Result> { + let backend = + ClusterStore::for_storage_root(storage_root).map_err(|diagnostic| vec![diagnostic])?; + read_root_bound_snapshot_with_store(&backend).await +} + +async fn read_root_bound_snapshot_with_store( + backend: &ClusterStore, +) -> Result> { + let snapshot = read_snapshot_with_store(backend).await?; + let canonical_root = backend + .canonical_root() + .map_err(|diagnostic| vec![diagnostic])?; + Ok(RootBoundServingSnapshot { + snapshot, + canonical_root, + }) } /// Cluster root for a graph **storage URI** of the cluster layout @@ -221,7 +280,7 @@ fn cluster_root_of_graph_layout(graph_uri: &str) -> Option { } async fn read_snapshot_with_store( - backend: ClusterStore, + backend: &ClusterStore, ) -> Result> { let mut diagnostics: Vec = Vec::new(); let mut startup_diagnostics: Vec = Vec::new(); @@ -471,7 +530,6 @@ async fn read_snapshot_with_store( return Err(diagnostics); } Ok(ServingSnapshot { - canonical_root: backend.canonical_root().map_err(|error| vec![error])?, graphs, queries, policies, diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index e61be1c57..71815e4c4 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -4301,8 +4301,18 @@ async fn storage_root_file_uri_relocates_the_cluster() { assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); assert!(!dir.path().join("graphs").exists()); - // The serving snapshot follows the root. + // Both readers follow the declared root, never the config directory. let snapshot = read_serving_snapshot(dir.path()).await.unwrap(); + let bound = read_root_bound_serving_snapshot(dir.path()).await.unwrap(); + assert_eq!( + bound.canonical_root(), + format!( + "file://{}", + fs::canonicalize(storage.path()).unwrap().display() + ) + ); + assert_eq!(bound.snapshot().state_cas, snapshot.state_cas); + assert!( snapshot.graphs[0].root.starts_with(storage.path()), "{:?}", @@ -4423,15 +4433,22 @@ async fn serving_snapshot_reads_converged_cluster() { let snapshot = read_serving_snapshot(dir.path()) .await .expect("converged cluster must serve"); + let bound = read_root_bound_serving_snapshot(dir.path()).await.unwrap(); assert_eq!( - snapshot.canonical_root, + bound.canonical_root(), format!("file://{}", fs::canonicalize(dir.path()).unwrap().display()) ); - let direct = read_serving_snapshot_from_storage(&snapshot.canonical_root) + assert_eq!(bound.snapshot().state_cas, snapshot.state_cas); + let direct = read_serving_snapshot_from_storage(bound.canonical_root()) + .await + .unwrap(); + let direct_bound = read_root_bound_serving_snapshot_from_storage(bound.canonical_root()) .await .unwrap(); - assert_eq!(direct.canonical_root, snapshot.canonical_root); + assert_eq!(direct_bound.canonical_root(), bound.canonical_root()); + assert_eq!(direct_bound.snapshot().state_cas, snapshot.state_cas); assert_eq!(direct.state_cas, snapshot.state_cas); + assert_eq!(bound.into_snapshot().config_digest, snapshot.config_digest); assert_eq!(snapshot.graphs.len(), 1); assert_eq!(snapshot.graphs[0].graph_id, "knowledge"); assert!(snapshot.graphs[0].root.ends_with("graphs/knowledge.omni")); diff --git a/crates/omnigraph-server/src/data_tokens.rs b/crates/omnigraph-server/src/data_tokens.rs index 6907fdeb8..8bb1c4632 100644 --- a/crates/omnigraph-server/src/data_tokens.rs +++ b/crates/omnigraph-server/src/data_tokens.rs @@ -5,7 +5,6 @@ use std::collections::HashSet; use std::fs::OpenOptions; use std::io::Read; use std::path::Path; -use std::sync::Arc; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -18,7 +17,7 @@ use sha2::{Digest, Sha256}; use url::Url; use crate::graph_id::GraphId; -use crate::identity::{AuthSource, ResolvedActor, Scope}; +use crate::identity::{AuthenticatedActor, ResolvedActor}; pub const MAX_TOKEN_BYTES: usize = 8_192; pub const MAX_TRUST_BYTES: usize = 65_536; @@ -200,10 +199,19 @@ impl DataTokenTrust { }) } - /// Verify using an explicit admission time. Failure is deliberately opaque: - /// callers must not log a credential or expose its unverified claims. + /// Verify and return the legacy identity projection. For authenticated + /// claims and request authorization, use [`Self::verify_authenticated_at`]. #[must_use] pub fn verify_at(&self, token: &str, now: u64) -> Option { + self.verify_authenticated_at(token, now) + .map(|actor| actor.actor().clone()) + } + + /// Verify using an explicit admission time and retain the signed grant + /// ceiling. Failure is deliberately opaque: callers must not log a + /// credential or expose its unverified claims. + #[must_use] + pub fn verify_authenticated_at(&self, token: &str, now: u64) -> Option { if token.len() > MAX_TOKEN_BYTES { return None; } @@ -232,14 +240,7 @@ impl DataTokenTrust { if !self.valid_claims(&claims, now) { return None; } - Some(ResolvedActor { - actor_id: Arc::from(format!("principal:{}", claims.sub)), - tenant_id: None, - scopes: vec![Scope::DataToken], - source: AuthSource::SignedData, - data_token: Some(Arc::new(claims)), - selected_graph: None, - }) + Some(AuthenticatedActor::signed(claims)) } fn valid_claims(&self, claims: &DataTokenClaims, now: u64) -> bool { diff --git a/crates/omnigraph-server/src/data_tokens/tests.rs b/crates/omnigraph-server/src/data_tokens/tests.rs index b0ba21023..7aff8e44f 100644 --- a/crates/omnigraph-server/src/data_tokens/tests.rs +++ b/crates/omnigraph-server/src/data_tokens/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::AuthSource; use p256::ecdsa::{SigningKey, signature::Signer}; use serde_json::{Value, json}; @@ -43,13 +44,18 @@ fn issuer_golden_signature_and_per_graph_ceiling() { let trust = trust(); let now = fixture["verification_time"].as_u64().unwrap(); let mut actor = trust - .verify_at(fixture["token"].as_str().unwrap(), now) + .verify_authenticated_at(fixture["token"].as_str().unwrap(), now) .unwrap(); assert_eq!( actor.actor_id_str(), format!("principal:{}", fixture["claims"]["sub"].as_str().unwrap()) ); assert_eq!(actor.source, AuthSource::SignedData); + let projected: ResolvedActor = trust + .verify_at(fixture["token"].as_str().unwrap(), now) + .unwrap(); + assert_eq!(projected.actor_id, actor.actor().actor_id); + assert_eq!(projected.source, actor.source); assert_eq!( serde_json::to_value(actor.data_claims().unwrap()).unwrap(), fixture["claims"] @@ -325,7 +331,7 @@ fn action_ceiling_has_no_implicit_permissions() { for allowed in actions { let mut claims = fixture["claims"].clone(); claims["grants"] = json!([{"graph_id":"graph-a","actions":[allowed]}]); - let mut actor = trust.verify_at(&sign(&claims), now).unwrap(); + let mut actor = trust.verify_authenticated_at(&sign(&claims), now).unwrap(); assert!(actor.select_graph(&GraphId::try_from("graph-a").unwrap())); for checked in actions { assert_eq!( diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index cd9ce4c1a..a81284f32 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -101,7 +101,7 @@ pub(crate) async fn server_ready( /// deterministic output across requests). pub(crate) async fn server_graphs_list( State(state): State, - actor: Option>, + actor: Option>, ) -> std::result::Result, ApiError> { let registry = &state.routing().registry; @@ -279,6 +279,10 @@ pub(crate) async fn require_bearer_auth( mut request: Request, next: Next, ) -> std::result::Result { + // Request extensions supplied by an embedder are projections, never an + // authentication result for this request. Rebuild both from its credential. + request.extensions_mut().remove::(); + request.extensions_mut().remove::(); if !state.requires_bearer_auth() { return Ok(next.run(request).await); } @@ -298,6 +302,7 @@ pub(crate) async fn require_bearer_auth( let Some(actor) = state.authenticate_bearer_token(provided_token) else { return Err(ApiError::unauthorized("invalid bearer token")); }; + request.extensions_mut().insert(actor.actor().clone()); request.extensions_mut().insert(actor); Ok(next.run(request).await) @@ -340,7 +345,7 @@ pub(crate) async fn resolve_graph_handle( })?; let graph_id = GraphId::try_from(graph_id_str.to_string()) .map_err(|err| ApiError::bad_request(err.to_string()))?; - if let Some(actor) = request.extensions_mut().get_mut::() { + if let Some(actor) = request.extensions_mut().get_mut::() { if !actor.select_graph(&graph_id) { return Err(ApiError::forbidden("credential does not permit this graph")); } @@ -408,7 +413,7 @@ pub(crate) enum Authz { /// request. See `actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers` /// at `tests/server.rs`. pub(crate) fn authorize( - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, policy: Option<&PolicyEngine>, request: PolicyRequest, ) -> std::result::Result { @@ -504,7 +509,7 @@ pub(crate) fn authorize( /// [`Authz`] decision directly to hide a denial as a 404 while letting an /// operational failure keep its true status. pub(crate) fn authorize_request( - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, policy: Option<&PolicyEngine>, request: PolicyRequest, ) -> std::result::Result<(), ApiError> { @@ -534,7 +539,7 @@ pub(crate) fn authorize_request( /// branch. Defaults to `main` when `branch` is omitted. Read-only. pub(crate) async fn server_snapshot( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Query(query): Query, ) -> std::result::Result, ApiError> { let branch = query.branch.unwrap_or_else(|| "main".to_string()); @@ -608,7 +613,7 @@ pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName, /// signal. pub(crate) async fn server_read( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { let output = run_query( @@ -661,7 +666,7 @@ pub(crate) async fn server_read( /// 400; send them to `POST /mutate`. pub(crate) async fn server_query( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result, ApiError> { let output = run_query( @@ -759,7 +764,7 @@ struct BlobBinaryBody(Vec); /// resolution share the exact helper used by `/query`. pub(crate) async fn server_blob_get( Extension(handle): Extension>, - actor: Option>, + actor: Option>, headers: HeaderMap, query: std::result::Result, QueryRejection>, ) -> std::result::Result { @@ -823,7 +828,7 @@ pub(crate) async fn server_blob_get( /// deliberately ignored while If-None-Match is still evaluated. pub(crate) async fn server_blob_head( Extension(handle): Extension>, - actor: Option>, + actor: Option>, headers: HeaderMap, query: std::result::Result, QueryRejection>, ) -> std::result::Result { @@ -846,7 +851,7 @@ fn parse_blob_read_query( async fn read_blob_for_delivery( handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, query: BlobReadQuery, ) -> std::result::Result { let target = resolve_authorized_read_target(handle, actor, query.branch, query.snapshot) @@ -918,7 +923,7 @@ fn redact_blob_api_error(mapped: ApiError) -> ApiError { pub(crate) async fn server_export( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, request: std::result::Result, JsonRejection>, ) -> std::result::Result { let Json(request) = request @@ -1073,7 +1078,7 @@ fn reject_graph_commit_expected_head( pub(crate) async fn run_mutate( state: AppState, handle: Arc, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, door: Door, query: &str, name: Option<&str>, @@ -1164,7 +1169,7 @@ pub(crate) async fn run_mutate( /// reads are not admission-gated, so there is no `state.workload` consumer. pub(crate) async fn run_query( handle: Arc, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, door: Door, query: &str, name: Option<&str>, @@ -1221,7 +1226,7 @@ pub(crate) async fn run_query( /// helper so snapshot-to-policy-branch resolution cannot drift by route. pub(crate) async fn resolve_authorized_read_target( handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, branch: Option, snapshot: Option, ) -> std::result::Result { @@ -1287,7 +1292,7 @@ pub(crate) async fn resolve_authorized_read_target( pub(crate) async fn server_change( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, headers: axum::http::HeaderMap, Json(request): Json, ) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { @@ -1356,7 +1361,7 @@ pub(crate) async fn server_change( pub(crate) async fn server_mutate( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, headers: axum::http::HeaderMap, Json(request): Json, ) -> std::result::Result, ApiError> { @@ -1408,7 +1413,7 @@ pub(crate) async fn server_mutate( pub(crate) async fn server_mutate_if_graph_commit( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, headers: axum::http::HeaderMap, Json(request): Json, ) -> std::result::Result, ApiError> { @@ -1486,7 +1491,7 @@ pub(crate) fn parse_optional_invoke_body( pub(crate) async fn server_invoke_query( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Path(QueryNamePath { name }): Path, headers: axum::http::HeaderMap, body: Bytes, @@ -1528,7 +1533,7 @@ pub(crate) async fn server_invoke_query( pub(crate) async fn server_invoke_query_if_graph_commit( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Path(QueryNamePath { name }): Path, headers: axum::http::HeaderMap, body: Bytes, @@ -1540,7 +1545,7 @@ pub(crate) async fn server_invoke_query_if_graph_commit( async fn invoke_stored_query( state: AppState, handle: Arc, - actor: Option>, + actor: Option>, name: String, body: Bytes, expected_head: Option, @@ -1677,7 +1682,7 @@ async fn invoke_stored_query( /// lacks (a known gap until per-query authorization lands). pub(crate) async fn server_list_queries( Extension(handle): Extension>, - actor: Option>, + actor: Option>, ) -> std::result::Result, ApiError> { authorize_request( actor.as_ref().map(|Extension(actor)| actor), @@ -1719,7 +1724,7 @@ pub(crate) async fn server_list_queries( /// accepted schema view; the system type is never appended to customer source. pub(crate) async fn server_schema_get( Extension(handle): Extension>, - actor: Option>, + actor: Option>, ) -> std::result::Result, ApiError> { authorize_request( actor.as_ref().map(|Extension(actor)| actor), @@ -1769,7 +1774,7 @@ pub(crate) async fn server_schema_get( pub(crate) async fn server_schema_apply( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result, ApiError> { let actor_arc = actor @@ -1842,7 +1847,7 @@ pub(crate) async fn server_schema_apply( /// Authorize one load target without touching request data. async fn authorize_load_scope( handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, branch: &str, from: Option<&str>, ) -> std::result::Result<(), ApiError> { @@ -1892,7 +1897,7 @@ async fn authorize_load_scope( async fn run_ingest( state: AppState, handle: Arc, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, request: IngestRequest, ) -> std::result::Result { let branch = request.branch.unwrap_or_else(|| "main".to_string()); @@ -1959,7 +1964,7 @@ async fn run_ingest( pub(crate) async fn server_load( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result, ApiError> { Ok(Json( @@ -2034,7 +2039,7 @@ async fn collect_graph_batch_body(body: Body) -> std::result::Result, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Query(query): Query, request: Request, ) -> std::result::Result, ApiError> { @@ -2131,7 +2136,7 @@ pub(crate) async fn server_load_ndjson( pub(crate) async fn server_ingest( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { let output = run_ingest( @@ -2165,7 +2170,7 @@ pub(crate) async fn server_ingest( /// `branch list` on `POST /query` runs the same body. pub(crate) async fn server_branch_list( Extension(handle): Extension>, - actor: Option>, + actor: Option>, ) -> std::result::Result, ApiError> { let branches = branch_list_body(&handle, actor.as_ref().map(|Extension(actor)| actor)).await?; Ok(Json(BranchListOutput { branches })) @@ -2175,7 +2180,7 @@ pub(crate) async fn server_branch_list( /// scope-free `read` check, then the names in byte order. async fn branch_list_body( handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, ) -> std::result::Result, ApiError> { authorize_request( actor, @@ -2221,7 +2226,7 @@ async fn branch_list_body( pub(crate) async fn server_branch_create( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result, ApiError> { let from = request.from.unwrap_or_else(|| "main".to_string()); @@ -2246,7 +2251,7 @@ pub(crate) async fn server_branch_create( async fn branch_create_body( state: &AppState, handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, from: &str, name: &str, ) -> std::result::Result<(), ApiError> { @@ -2321,7 +2326,7 @@ pub(crate) struct BranchPath { pub(crate) async fn server_branch_delete( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Path(BranchPath { branch }): Path, ) -> std::result::Result, ApiError> { let actor_ref = actor.as_ref().map(|Extension(actor)| actor); @@ -2338,7 +2343,7 @@ pub(crate) async fn server_branch_delete( async fn branch_delete_body( state: &AppState, handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, name: &str, ) -> std::result::Result<(), ApiError> { let actor_arc = actor @@ -2401,7 +2406,7 @@ async fn branch_delete_body( pub(crate) async fn server_branch_merge( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result, ApiError> { let target = request.target.unwrap_or_else(|| "main".to_string()); @@ -2431,7 +2436,7 @@ pub(crate) async fn server_branch_merge( async fn branch_merge_body( state: &AppState, handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, source: &str, target: &str, ) -> std::result::Result { @@ -2468,7 +2473,7 @@ async fn branch_merge_body( /// already durable, so the request must not report failure for it. async fn delete_merged_source_branch( handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, source: &str, ) -> std::result::Result<(), String> { match authorize( @@ -2516,7 +2521,7 @@ async fn delete_merged_source_branch( /// order. Read-only. pub(crate) async fn server_commit_list( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Query(query): Query, ) -> std::result::Result, ApiError> { // An omitted `branch` means main's history, so the policy gate must @@ -2573,7 +2578,7 @@ pub(crate) struct CommitPath { /// metadata. Read-only. pub(crate) async fn server_commit_show( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Path(CommitPath { commit_id }): Path, ) -> std::result::Result, ApiError> { authorize_request( @@ -2909,7 +2914,7 @@ pub(crate) fn parse_change_query( /// a large commit continues via the opaque `page_token`. pub(crate) async fn server_commit_changes( Extension(handle): Extension>, - actor: Option>, + actor: Option>, Path(CommitPath { commit_id }): Path, axum::extract::RawQuery(raw): axum::extract::RawQuery, ) -> std::result::Result, ApiError> { @@ -3121,7 +3126,7 @@ fn normalize_change_branch(branch: Option<&str>) -> std::result::Result>, - actor: Option>, + actor: Option>, axum::extract::RawQuery(raw): axum::extract::RawQuery, ) -> std::result::Result, ApiError> { let params = parse_change_query(raw.as_deref(), CHANGE_FEED_PARAMS)?; @@ -3199,7 +3204,7 @@ pub(crate) async fn server_changes_feed( pub(crate) async fn server_changes_baseline( State(state): State, Extension(handle): Extension>, - actor: Option>, + actor: Option>, Json(request): Json, ) -> std::result::Result { let branch = normalize_change_branch(request.branch.as_deref())?; diff --git a/crates/omnigraph-server/src/handlers/dispatch.rs b/crates/omnigraph-server/src/handlers/dispatch.rs index 637fbf02f..48eeb613a 100644 --- a/crates/omnigraph-server/src/handlers/dispatch.rs +++ b/crates/omnigraph-server/src/handlers/dispatch.rs @@ -113,7 +113,7 @@ pub(super) fn refuse_statement_envelope( pub(super) async fn run_branch_statement( state: &AppState, handle: &GraphHandle, - actor: Option<&ResolvedActor>, + actor: Option<&AuthenticatedActor>, write: BranchWrite, ) -> std::result::Result { let query_name = write.statement_name().to_string(); diff --git a/crates/omnigraph-server/src/identity.rs b/crates/omnigraph-server/src/identity.rs index b5fd2c008..3f0fec09d 100644 --- a/crates/omnigraph-server/src/identity.rs +++ b/crates/omnigraph-server/src/identity.rs @@ -157,25 +157,16 @@ pub enum AuthSource { SignedData, } -/// Server-resolved actor identity. Replaces the previous -/// `AuthenticatedActor(Arc)` from `lib.rs`. -/// -/// The fields are populated by `authenticate_bearer_token` after a successful -/// static hash match or signed credential verification. -/// **Clients cannot set any of these fields directly** -/// — this is the MR-731 invariant. See `authorize_request` in `lib.rs` for the -/// chokepoint that overwrites any client-supplied actor identity. -/// -/// The static constructor uses `Scope::Full`; the data-token verifier attaches -/// validated claims and uses `Scope::DataToken`. +/// Public identity projection for embedders. Its legacy fields remain +/// constructible; this record alone is never authenticated request authority. +/// The server uses [`AuthenticatedActor`] internally, populated only after a +/// static credential match or signed credential verification. #[derive(Debug, Clone)] pub struct ResolvedActor { pub actor_id: Arc, pub tenant_id: Option, pub scopes: Vec, pub source: AuthSource, - pub(crate) data_token: Option>, - pub(crate) selected_graph: Option, } impl ResolvedActor { @@ -187,8 +178,6 @@ impl ResolvedActor { tenant_id: None, scopes: vec![Scope::Full], source: AuthSource::Static, - data_token: None, - selected_graph: None, } } @@ -197,6 +186,54 @@ impl ResolvedActor { pub fn actor_id_str(&self) -> &str { &self.actor_id } +} + +/// Verified request identity and its immutable signed grant ceiling. +/// +/// Public identity fields are exposed only through a shared projection. There +/// is no public constructor or mutable projection: a caller-created +/// [`ResolvedActor`] cannot manufacture or widen request authority. +#[derive(Debug, Clone)] +pub struct AuthenticatedActor { + actor: ResolvedActor, + data_token: Option>, + selected_graph: Option, +} + +impl std::ops::Deref for AuthenticatedActor { + type Target = ResolvedActor; + + fn deref(&self) -> &Self::Target { + self.actor() + } +} + +impl AuthenticatedActor { + pub(crate) fn cluster_static(actor_id: Arc) -> Self { + Self { + actor: ResolvedActor::cluster_static(actor_id), + data_token: None, + selected_graph: None, + } + } + + pub(crate) fn signed(claims: crate::data_tokens::DataTokenClaims) -> Self { + Self { + actor: ResolvedActor { + actor_id: Arc::from(format!("principal:{}", claims.sub)), + tenant_id: None, + scopes: vec![Scope::DataToken], + source: AuthSource::SignedData, + }, + data_token: Some(Arc::new(claims)), + selected_graph: None, + } + } + + /// Identity metadata, without mutable access to the authenticated state. + pub fn actor(&self) -> &ResolvedActor { + &self.actor + } /// Authenticated signed claims, excluding the original bearer plaintext. pub fn data_claims(&self) -> Option<&crate::data_tokens::DataTokenClaims> { diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index e9ba56c8b..f02dc3578 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -5,7 +5,10 @@ mod handlers; mod settings; use handlers::*; use settings::*; -pub use settings::{ServerRuntimeState, classify_server_runtime_state, load_server_settings}; +pub use settings::{ + ServerRuntimeState, classify_server_runtime_state, load_server_settings, + load_server_settings_with_data_token_trust, +}; pub mod auth; pub mod data_tokens; pub mod graph_id; @@ -16,7 +19,7 @@ pub mod registry; pub mod workload; pub use graph_id::GraphId; -pub use identity::{AuthSource, GraphKey, ResolvedActor, Scope, TenantId}; +pub use identity::{AuthSource, AuthenticatedActor, GraphKey, ResolvedActor, Scope, TenantId}; pub use registry::{GraphHandle, GraphRegistry, InsertError, RegistryLookup, RegistrySnapshot}; use crate::queries::{QueryRegistry, check, format_check_breakages}; @@ -177,10 +180,6 @@ pub struct ServerConfig { /// routes. pub mode: ServerConfigMode, pub bind: String, - /// Resolved by the same Core snapshot as the graphs; never inferred from a token. - pub canonical_root: String, - /// Optional immutable public trust for RFC 0053 signed data credentials. - pub data_token_trust: Option, /// Operator opt-in for fully-unauthenticated dev mode (MR-723). /// When no static tokens, signed-token trust, or policy are configured, /// `serve()` refuses to start unless this is true (set via @@ -203,6 +202,34 @@ pub struct ServerConfig { pub shutdown_grace: std::time::Duration, } +/// Applied server settings paired with already validated offline token trust. +/// +/// Constructed only by [`load_server_settings_with_data_token_trust`]. The +/// settings are exposed read-only so their graphs cannot be replaced after the +/// canonical serving root has been checked against the trust document. +#[derive(Debug, Clone)] +pub struct ManagedServerConfig { + config: ServerConfig, + canonical_root: String, + trust: data_tokens::DataTokenTrust, +} + +impl ManagedServerConfig { + pub fn config(&self) -> &ServerConfig { + &self.config + } + + pub fn canonical_root(&self) -> &str { + &self.canonical_root + } + + /// Change only the shutdown bound, preserving the validated root binding. + pub fn with_shutdown_grace(mut self, grace: std::time::Duration) -> Self { + self.config.shutdown_grace = grace; + self + } +} + /// The default bound on graceful shutdown. pub const DEFAULT_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(25); @@ -743,7 +770,7 @@ impl AppState { self.routing.registry.snapshot_ref().any_per_graph_policy } - fn authenticate_bearer_token(&self, provided_token: &str) -> Option { + fn authenticate_bearer_token(&self, provided_token: &str) -> Option { // Hash the incoming token and compare against every stored digest in // constant time. Iterate all entries unconditionally so total work — // and therefore response timing — doesn't depend on which slot matches. @@ -754,14 +781,14 @@ impl AppState { matched = Some(Arc::clone(actor)); } } - matched.map(ResolvedActor::cluster_static).or_else(|| { + matched.map(AuthenticatedActor::cluster_static).or_else(|| { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok()? .as_secs(); self.data_token_trust .as_ref()? - .verify_at(provided_token, now) + .verify_authenticated_at(provided_token, now) }) } } @@ -1856,7 +1883,7 @@ pub fn build_app(state: AppState) -> Router { // The per-graph protected routes, identical in single + multi mode. // Two middleware layers wrap them (outer first, inner last): // 1. `require_bearer_auth` — extracts the bearer token and injects - // `ResolvedActor` (or rejects 401). + // `AuthenticatedActor` (or rejects 401). // 2. `resolve_graph_handle` — injects `Arc` based on // the active mode (single: the only handle; multi: lookup by // `{graph_id}` in the URI path). @@ -1966,6 +1993,19 @@ pub fn build_app(state: AppState) -> Router { } pub async fn serve(config: ServerConfig) -> Result<()> { + serve_config(config, None).await +} + +/// Serve settings whose offline data-token trust was validated against their +/// applied snapshot's canonical root before any graph engine open. +pub async fn serve_with_data_token_trust(config: ManagedServerConfig) -> Result<()> { + serve_config(config.config, Some(config.trust)).await +} + +async fn serve_config( + config: ServerConfig, + data_token_trust: Option, +) -> Result<()> { // RFC 0049: the signal listener is installed before anything else, so // the shutdown bound covers startup. On the signal it sets `draining`, // arms the watchdog thread, and releases the graceful shutdown. @@ -1982,11 +2022,6 @@ pub async fn serve(config: ServerConfig) -> Result<()> { }); } - let data_token_trust = config - .data_token_trust - .as_ref() - .map(|path| data_tokens::DataTokenTrust::read(path, &config.canonical_root)) - .transpose()?; let token_source = resolve_token_source().await?; info!(source = token_source.name(), "loaded bearer token source"); let tokens = token_source.load().await?; diff --git a/crates/omnigraph-server/src/main.rs b/crates/omnigraph-server/src/main.rs index f8de8cae0..b5c07c026 100644 --- a/crates/omnigraph-server/src/main.rs +++ b/crates/omnigraph-server/src/main.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use clap::Parser; use color_eyre::eyre::Result; use omnigraph_server::{ - ServerConfig, init_tracing, load_server_settings, resolve_shutdown_grace, serve, + init_tracing, load_server_settings, load_server_settings_with_data_token_trust, + resolve_shutdown_grace, serve, serve_with_data_token_trust, }; #[derive(Debug, Parser)] @@ -53,14 +54,29 @@ async fn main() -> Result<()> { init_tracing(); let cli = Cli::parse(); - let mut settings: ServerConfig = load_server_settings( - cli.cluster.as_ref(), - cli.bind, - cli.unauthenticated, - cli.require_all_graphs, - ) - .await?; - settings.shutdown_grace = resolve_shutdown_grace(cli.shutdown_grace_seconds)?; - settings.data_token_trust = cli.data_token_trust; - serve(settings).await + match cli.data_token_trust { + Some(trust_path) => { + let settings = load_server_settings_with_data_token_trust( + cli.cluster.as_ref(), + cli.bind, + cli.unauthenticated, + cli.require_all_graphs, + &trust_path, + ) + .await? + .with_shutdown_grace(resolve_shutdown_grace(cli.shutdown_grace_seconds)?); + serve_with_data_token_trust(settings).await + } + None => { + let mut settings = load_server_settings( + cli.cluster.as_ref(), + cli.bind, + cli.unauthenticated, + cli.require_all_graphs, + ) + .await?; + settings.shutdown_grace = resolve_shutdown_grace(cli.shutdown_grace_seconds)?; + serve(settings).await + } + } } diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index bef43ce88..c48471c42 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -3,6 +3,7 @@ //! modularization). use super::*; +use std::path::Path; /// Build serving settings from a cluster directory's applied revision /// (RFC-005 §D2): graphs at derived roots, stored queries from verified @@ -21,28 +22,47 @@ pub(crate) async fn load_cluster_settings( // Any supported scheme-qualified argument (s3://, az://, file://) is a storage root; a // bare path is a config directory. let cluster_arg = cluster_dir.to_string_lossy(); - let diagnostic_cluster = omnigraph::storage::redacted_storage_uri(cluster_arg.as_ref()); let snapshot = if cluster_arg.contains("://") { omnigraph_cluster::read_serving_snapshot_from_storage(cluster_arg.as_ref()).await } else { omnigraph_cluster::read_serving_snapshot(cluster_dir).await } - .map_err(|diagnostics| { - let details = diagnostics - .iter() - .map(|diagnostic| { - format!( - "[{}] {}: {}", - diagnostic.code, diagnostic.path, diagnostic.message - ) - }) - .collect::>() - .join("\n "); - eyre!( - "the cluster at '{}' is not ready to serve:\n {details}", - diagnostic_cluster - ) - })?; + .map_err(|diagnostics| serving_snapshot_error(cluster_dir, &diagnostics))?; + settings_from_snapshot( + cluster_dir, + cli_bind, + cli_allow_unauthenticated, + cli_require_all_graphs, + snapshot, + ) +} + +fn serving_snapshot_error( + cluster_dir: &Path, + diagnostics: &[omnigraph_cluster::Diagnostic], +) -> color_eyre::Report { + let diagnostic_cluster = + omnigraph::storage::redacted_storage_uri(&cluster_dir.to_string_lossy()); + let details = diagnostics + .iter() + .map(|diagnostic| { + format!( + "[{}] {}: {}", + diagnostic.code, diagnostic.path, diagnostic.message + ) + }) + .collect::>() + .join("\n "); + eyre!("the cluster at '{diagnostic_cluster}' is not ready to serve:\n {details}") +} + +fn settings_from_snapshot( + cluster_dir: &Path, + cli_bind: Option, + cli_allow_unauthenticated: bool, + cli_require_all_graphs: bool, + snapshot: omnigraph_cluster::ServingSnapshot, +) -> Result { for diagnostic in &snapshot.diagnostics { warn!( code = %diagnostic.code, @@ -203,12 +223,10 @@ pub(crate) async fn load_cluster_settings( Ok(ServerConfig { mode: ServerConfigMode::Multi { graphs, - config_path: cluster_dir.clone(), + config_path: cluster_dir.to_path_buf(), server_policy, }, bind: cli_bind.unwrap_or_else(|| "127.0.0.1:8080".to_string()), - canonical_root: snapshot.canonical_root, - data_token_trust: None, allow_unauthenticated: cli_allow_unauthenticated || env_unauth, require_all_graphs, witness, @@ -228,6 +246,51 @@ pub async fn load_server_settings( cli_allow_unauthenticated: bool, cli_require_all_graphs: bool, ) -> Result { + let cluster_dir = required_cluster(cli_cluster)?; + load_cluster_settings( + cluster_dir, + cli_bind, + cli_allow_unauthenticated, + cli_require_all_graphs, + ) + .await +} + +/// Load applied serving settings with explicitly enabled offline data-token +/// trust. Root metadata and the snapshot come from the same opened Core store; +/// trust is validated before any graph engine can be opened for recovery. +pub async fn load_server_settings_with_data_token_trust( + cli_cluster: Option<&PathBuf>, + cli_bind: Option, + cli_allow_unauthenticated: bool, + cli_require_all_graphs: bool, + trust_path: &Path, +) -> Result { + let cluster_dir = required_cluster(cli_cluster)?; + let cluster_arg = cluster_dir.to_string_lossy(); + let bound = if cluster_arg.contains("://") { + omnigraph_cluster::read_root_bound_serving_snapshot_from_storage(&cluster_arg).await + } else { + omnigraph_cluster::read_root_bound_serving_snapshot(cluster_dir).await + } + .map_err(|diagnostics| serving_snapshot_error(cluster_dir, &diagnostics))?; + let canonical_root = bound.canonical_root().to_string(); + let trust = data_tokens::DataTokenTrust::read(trust_path, &canonical_root)?; + let config = settings_from_snapshot( + cluster_dir, + cli_bind, + cli_allow_unauthenticated, + cli_require_all_graphs, + bound.into_snapshot(), + )?; + Ok(ManagedServerConfig { + config, + canonical_root, + trust, + }) +} + +fn required_cluster(cli_cluster: Option<&PathBuf>) -> Result<&PathBuf> { let Some(cluster_dir) = cli_cluster else { bail!( "omnigraph-server boots from a cluster: pass --cluster \ @@ -236,13 +299,7 @@ pub async fn load_server_settings( has been removed." ); }; - load_cluster_settings( - cluster_dir, - cli_bind, - cli_allow_unauthenticated, - cli_require_all_graphs, - ) - .await + Ok(cluster_dir) } fn env_flag(name: &str) -> bool { @@ -409,7 +466,7 @@ mod tests { #[test] fn authorize_splits_decision_from_operational_error() { use super::{ - Authz, PolicyAction, PolicyCompiler, PolicyConfig, PolicyRequest, ResolvedActor, + AuthenticatedActor, Authz, PolicyAction, PolicyCompiler, PolicyConfig, PolicyRequest, authorize, }; use std::sync::Arc; @@ -421,7 +478,7 @@ mod tests { target_branch: None, } } - let actor = ResolvedActor::cluster_static(Arc::from("act-alice")); + let actor = AuthenticatedActor::cluster_static(Arc::from("act-alice")); // --- No policy engine installed (open / default-deny modes) --- // A server-scoped action is denied in every no-policy state. @@ -679,8 +736,6 @@ mod tests { server_policy: Some(crate::PolicySource::File(policy_path)), }, bind: "127.0.0.1:0".to_string(), - canonical_root: String::new(), - data_token_trust: None, allow_unauthenticated: false, require_all_graphs: false, witness: crate::BootWitness::default(), @@ -737,8 +792,6 @@ mod tests { server_policy: None, }, bind: "127.0.0.1:0".to_string(), - canonical_root: String::new(), - data_token_trust: None, allow_unauthenticated: false, require_all_graphs: false, witness: crate::BootWitness::default(), diff --git a/crates/omnigraph-server/tests/auth_policy.rs b/crates/omnigraph-server/tests/auth_policy.rs index 878f80a11..b28001714 100644 --- a/crates/omnigraph-server/tests/auth_policy.rs +++ b/crates/omnigraph-server/tests/auth_policy.rs @@ -46,10 +46,28 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { {"graph_id":"default","actions":["read"]}, {"graph_id":"reports","actions":["change"]} ])); + let wider = tokens.token(json!([{"graph_id":"default","actions":["change"]}])); + let unrelated_authentication = tokens + .trust + .verify_authenticated_at( + &wider, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap(); let request = || { Request::builder().uri(g("/mutate")).method(Method::POST) .header("authorization",format!("Bearer {read}")) .header("x-actor-id","breakglass") + .extension(omnigraph_server::ResolvedActor { + actor_id: "breakglass".into(), + tenant_id: None, + scopes: vec![omnigraph_server::Scope::Full], + source: omnigraph_server::AuthSource::Static, + }) + .extension(unrelated_authentication.clone()) .header("content-type","application/json") .body(Body::from(json!({"query":MUTATION_QUERIES,"name":"insert_person","params":{"name":"Signed","age":28},"branch":"main"}).to_string())).unwrap() }; @@ -60,7 +78,7 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { assert_eq!( status, StatusCode::FORBIDDEN, - "another graph's change grant must not leak" + "neither another graph's grant nor a forged public actor may widen signed authority" ); let (_, after) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; assert_eq!(after, before, "denial must not publish a commit"); @@ -143,6 +161,63 @@ async fn signed_data_tokens_narrow_policy_and_attribute_writes() { renewed_actors["rows"], actors["rows"], "a new credential for the same principal reuses its actor node" ); + let (_, before_branches) = json_response(&app, get_request(&g("/branches"), &read)).await; + let other_graph_create = tokens.token(json!([ + {"graph_id":"default","actions":["read"]}, + {"graph_id":"reports","actions":["branch_create"]} + ])); + for token in [&read, &wider, &other_graph_create] { + let (status, body) = json_response( + &app, + statement_request("/mutate", token, "branch create signed_branch"), + ) + .await; + assert_forbidden( + status, + body, + "branch statements retain the selected graph's action ceiling", + ); + } + let (_, after_branches) = json_response(&app, get_request(&g("/branches"), &read)).await; + assert_eq!( + after_branches, before_branches, + "denied branch statements have no effect" + ); + + let create = tokens.token(json!([{"graph_id":"default","actions":["branch_create"]}])); + let (status, body) = json_response( + &app, + statement_request("/mutate", &create, "branch create signed_branch"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["actor_id"], tokens.actor); + let (status, body) = json_response( + &app, + statement_request("/mutate", &create, "branch delete signed_branch"), + ) + .await; + assert_forbidden( + status, + body, + "branch creation authority cannot delete a branch", + ); + let (status, body) = + json_response(&app, statement_request("/query", &read, "branch list")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["rows"], + json!([{"name":"main"},{"name":"signed_branch"}]) + ); + + let mut hidden = statement_request("/query", &read, "branch list"); + *hidden.uri_mut() = "/graphs/hidden/query".parse().unwrap(); + let (status, body) = json_response(&app, hidden).await; + assert_forbidden( + status, + body, + "branch list cannot probe a graph outside the signed grant", + ); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 30f80e3dc..9e094ba9f 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -14,47 +14,103 @@ use tower::ServiceExt; mod support; use support::*; -#[tokio::test] -async fn data_trust_root_mismatch_refuses_before_recovery_open() { - use omnigraph_server::queries::QueryRegistry; +/// External consumers may construct and exhaustively destructure the legacy +/// public settings and identity records without opting into managed trust. +#[test] +fn legacy_public_struct_literals_and_destructuring_compile() { + use omnigraph_cluster::ServingSnapshot; use omnigraph_server::{ - BootWitness, DEFAULT_SHUTDOWN_GRACE, GraphStartupConfig, ServerConfig, ServerConfigMode, + AuthSource, BootWitness, DEFAULT_SHUTDOWN_GRACE, ResolvedActor, Scope, ServerConfig, + ServerConfigMode, }; - let tokens = data_tokens::DataTokens::new(); - let temp = tempfile::tempdir().unwrap(); - let graph = temp.path().join("graph.omni"); - let schema = fs::read_to_string(fixture("test.pg")).unwrap(); - Omnigraph::init(graph.to_str().unwrap(), &schema) - .await - .unwrap(); - // A read-write engine open normally cleans matching no-op schema staging. - // Wrong public trust must refuse before even that recovery effect. - let staging = graph.join("_schema.pg.staging"); - fs::write(&staging, &schema).unwrap(); - let trust_path = temp.path().join("trust.json"); - fs::write(&trust_path, serde_json::to_vec(&tokens.document).unwrap()).unwrap(); - let result = omnigraph_server::serve(ServerConfig { + let ServerConfig { + mode, + bind, + allow_unauthenticated, + require_all_graphs, + witness, + shutdown_grace, + } = ServerConfig { mode: ServerConfigMode::Multi { - graphs: vec![GraphStartupConfig { - graph_id: "graph-a".into(), - uri: graph.to_str().unwrap().into(), - policy: None, - embedding: None, - external_blob_policy: Default::default(), - queries: QueryRegistry::default(), - }], - config_path: temp.path().into(), + graphs: vec![], + config_path: "cluster".into(), server_policy: None, }, bind: "127.0.0.1:0".into(), - canonical_root: "file:///another-root".into(), - data_token_trust: Some(trust_path), allow_unauthenticated: true, - require_all_graphs: true, + require_all_graphs: false, witness: BootWitness::default(), shutdown_grace: DEFAULT_SHUTDOWN_GRACE, - }) + }; + assert!(matches!(mode, ServerConfigMode::Multi { .. })); + assert_eq!(bind, "127.0.0.1:0"); + assert!(allow_unauthenticated && !require_all_graphs); + assert!(witness.applied_graphs.is_empty()); + assert_eq!(shutdown_grace, DEFAULT_SHUTDOWN_GRACE); + + let ServingSnapshot { + graphs, + queries, + policies, + diagnostics, + config_digest, + state_revision, + state_cas, + applied_graphs, + quarantined_graphs, + } = ServingSnapshot { + graphs: vec![], + queries: vec![], + policies: vec![], + diagnostics: vec![], + config_digest: None, + state_revision: 0, + state_cas: None, + applied_graphs: vec![], + quarantined_graphs: vec![], + }; + assert!(graphs.is_empty() && queries.is_empty() && policies.is_empty()); + assert!(diagnostics.is_empty() && config_digest.is_none() && state_cas.is_none()); + assert_eq!(state_revision, 0); + assert!(applied_graphs.is_empty() && quarantined_graphs.is_empty()); + + let ResolvedActor { + actor_id, + tenant_id, + scopes, + source, + } = ResolvedActor { + actor_id: "legacy-actor".into(), + tenant_id: None, + scopes: vec![Scope::Full], + source: AuthSource::Static, + }; + assert_eq!(&*actor_id, "legacy-actor"); + assert!(tenant_id.is_none()); + assert_eq!(scopes, vec![Scope::Full]); + assert_eq!(source, AuthSource::Static); +} + +#[tokio::test] +async fn data_trust_root_mismatch_refuses_before_recovery_open() { + let tokens = data_tokens::DataTokens::new(); + let temp = converged_cluster_dir("").await; + let graph = temp.path().join("graphs/knowledge.omni"); + let schema = fs::read_to_string(temp.path().join("people.pg")).unwrap(); + // A read-write engine open normally cleans matching no-op schema staging. + // Wrong public trust must refuse before even that recovery effect. + let staging = graph.join("_schema.pg.staging"); + fs::write(&staging, &schema).unwrap(); + let trust_path = temp.path().join("trust.json"); + fs::write(&trust_path, serde_json::to_vec(&tokens.document).unwrap()).unwrap(); + let result = omnigraph_server::load_server_settings_with_data_token_trust( + Some(&temp.path().to_path_buf()), + Some("127.0.0.1:0".into()), + true, + true, + &trust_path, + ) .await; assert!( result @@ -68,6 +124,81 @@ async fn data_trust_root_mismatch_refuses_before_recovery_open() { ); } +#[tokio::test] +async fn managed_settings_bind_the_applied_store_not_the_config_directory() { + let mut tokens = data_tokens::DataTokens::new(); + let store = converged_cluster_dir("").await; + let config = tempfile::tempdir().unwrap(); + let canonical_root = format!( + "file://{}", + fs::canonicalize(store.path()).unwrap().display() + ); + fs::write( + config.path().join("cluster.yaml"), + format!("version: 1\nstorage: {canonical_root}\n"), + ) + .unwrap(); + let config_path = config.path().to_path_buf(); + let trust_path = config.path().join("trust.json"); + tokens.document["canonical_root"] = serde_json::json!(format!( + "file://{}", + fs::canonicalize(config.path()).unwrap().display() + )); + fs::write(&trust_path, serde_json::to_vec(&tokens.document).unwrap()).unwrap(); + assert!( + omnigraph_server::load_server_settings_with_data_token_trust( + Some(&config_path), + None, + true, + false, + &trust_path, + ) + .await + .unwrap_err() + .to_string() + .contains("serving-root binding") + ); + + tokens.document["canonical_root"] = serde_json::json!(canonical_root); + fs::write(&trust_path, serde_json::to_vec(&tokens.document).unwrap()).unwrap(); + let managed = omnigraph_server::load_server_settings_with_data_token_trust( + Some(&config_path), + None, + true, + false, + &trust_path, + ) + .await + .unwrap() + .with_shutdown_grace(std::time::Duration::from_secs(7)); + let legacy = cluster_settings(&config_path).await.unwrap(); + assert_eq!(managed.canonical_root(), canonical_root); + assert_eq!(managed.config().witness.state_cas, legacy.witness.state_cas); + assert_eq!(managed.config().witness.applied_graphs, vec!["knowledge"]); + assert_eq!( + managed.config().shutdown_grace, + std::time::Duration::from_secs(7) + ); + let omnigraph_server::ServerConfigMode::Multi { graphs, .. } = &managed.config().mode; + assert_eq!(graphs[0].graph_id, "knowledge"); + assert!(graphs[0].uri.contains("/graphs/knowledge.omni")); + + let direct = omnigraph_server::load_server_settings_with_data_token_trust( + Some(&std::path::PathBuf::from(&canonical_root)), + None, + true, + false, + &trust_path, + ) + .await + .unwrap(); + assert_eq!(direct.canonical_root(), canonical_root); + assert_eq!( + direct.config().witness.state_cas, + managed.config().witness.state_cas + ); +} + mod multi_graph_startup { use super::*; use omnigraph::storage::normalize_root_uri; diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 16ce26bb8..26416fecf 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -87,11 +87,11 @@ Servers do not hot-reload. Apply the new revision and restart every server that Bearer authentication is a server concern. Cedar mutation enforcement also lives in the engine's `_as` APIs so embedded and CLI writers cannot bypass it. Cluster policy application publishes the bundles and bindings; it does not replace either enforcement layer. The optional [offline data-token profile](../rfcs/0053-offline-data-token-verification.md) -uses immutable public trust loaded before graph open. The Core's serving -snapshot supplies the canonical storage root from the same resolution as the -applied revision; the server checks that root against trust without reading a -managed identity marker. The verifier resolves `principal:` and retains -per-graph action ceilings. Graph selection checks the ceiling before registry +uses immutable public trust loaded before graph open. The Core's opt-in +root-bound serving snapshot supplies the canonical storage root from the same +resolution as the applied revision; the server checks that root against trust +without reading a managed identity marker. The verifier resolves +`principal:` and retains per-graph action ceilings. Graph selection checks the ceiling before registry lookup; the common authorization gate checks actions before Cedar, which must explicitly permit signed identities even when no static credentials exist. Static credential authority remains unchanged. Issuer reachability is outside @@ -106,6 +106,36 @@ deadline (`--shutdown-grace-seconds`, default 25), kept by a thread and armed by a listener installed before graphs open, after which the process exits 2 without claiming success. +### Public embedding APIs + +Ordinary callers use `read_serving_snapshot` or +`read_serving_snapshot_from_storage`, `load_server_settings`, and `serve`. +`ServingSnapshot` and `ServerConfig` contain their ordinary public fields; +trust-disabled boot does not add root canonicalization solely for signed +credentials. + +Managed callers use `read_root_bound_serving_snapshot` or its `_from_storage` +counterpart to obtain an opaque `RootBoundServingSnapshot`. Its snapshot and +canonical-root accessors refer to the same opened store. Do not reconstruct +that binding by reopening a caller-supplied path. Server embedders use +`load_server_settings_with_data_token_trust` and +`serve_with_data_token_trust`; `ManagedServerConfig` keeps the configuration +and validated trust together. `with_shutdown_grace` changes only the shutdown +bound. A failed managed load must not be retried through ordinary `serve`. +The binary's `--data-token-trust FILE` selects this managed path. + +`ResolvedActor` is a public identity projection, not proof of authentication. +Middleware and protected handlers retain `AuthenticatedActor`, whose private +state carries verified claims and graph selection. `DataTokenTrust::verify_at` +returns the identity projection for existing callers; +`verify_authenticated_at` returns the opaque authenticated result used by the +server. Public actor construction cannot grant signed-token permissions. + +In-process hosts that assemble `AppState` and call its existing +`with_data_token_trust` method continue to own their graph/root binding. Use +the managed settings loader when the library should validate the applied +snapshot and trust binding together. + ## Azure boundary `az://container/prefix` uses the same control-object and graph-root model as diff --git a/docs/releases/v0.10.0.md b/docs/releases/v0.10.0.md index 3f68c993a..8ddf1cd81 100644 --- a/docs/releases/v0.10.0.md +++ b/docs/releases/v0.10.0.md @@ -20,6 +20,15 @@ verified whole-root backup for rollback and follow the ## Highlights +- **Managed folders preserve explicit CLI targets.** An explicit server, + profile, store, or supported cluster address keeps ordinary command behavior, + including beside malformed folder context. Other graph commands, aliases, + and local tools are no longer blocked by that context. Implicit managed + query/mutate refuses when an environment profile or default target competes + with the folder binding; select the intended target before retrying. Scoped + managed credentials and offline authorization remain unchanged. See + [managed data access](../user/cli/managed-data.md). + - **Blob reads through the engine, HTTP, and CLI.** Select a node or edge Blob property at a branch or snapshot. `blob get` streams bytes; `blob stat` reads metadata. HTTP GET/HEAD support ranges and conditional delivery. Managed @@ -110,6 +119,17 @@ verified whole-root backup for rollback and follow the ### CLI, HTTP, and embedded API +- Public `ServerConfig`, `ServingSnapshot`, and `ResolvedActor` retain their + construction contracts from before managed data-token support. Embedders + using the interim root/trust fields migrate to the additive managed boot + APIs in the [developer guide](../dev/control-plane.md#public-embedding-apis). + Ordinary boot avoids the added signed-trust canonicalization step; managed + boot keeps strict root verification and offline authorization. The binary + flag and trust-file format are unchanged. Callers that inspected claims on + the interim `ResolvedActor` use `verify_authenticated_at` and the returned + `AuthenticatedActor::data_claims` instead; `verify_at` remains an identity + projection. + - Graph-facing names now distinguish nodes/edges, types, entities, properties, graph-manifest versions, and published dataset versions. Legacy aliases such as `table_key`, `row_id`, ambiguous `manifest_version`, `rows_loaded`, and diff --git a/docs/rfcs/0053-offline-data-token-verification.md b/docs/rfcs/0053-offline-data-token-verification.md index 71c644b4e..be9ff7f9c 100644 --- a/docs/rfcs/0053-offline-data-token-verification.md +++ b/docs/rfcs/0053-offline-data-token-verification.md @@ -7,7 +7,7 @@ implementation: complete authors: - andrew created: 2026-09-05 -updated: 2026-09-05 +updated: 2026-09-06 discussion: https://github.com/ModernRelay/omnigraph/pull/633 supersedes: [] superseded_by: [] @@ -158,10 +158,12 @@ unknown request `kid` refuses without fetching anything. `canonical_root` is nonempty and at most 4,096 bytes. Issuer and endpoint origins are at most 2,048 bytes in addition to their containing object bounds. The server compares `canonical_root` with the actual root selected by -`--cluster`. The Core exposes `ServingSnapshot.canonical_root` from the same -store and snapshot resolution; it does not reparse configuration or reread -mutable state for this binding. Local file roots are canonical absolute -paths encoded as file URIs. A mismatched root refuses before graph engines +`--cluster`. The Core's opt-in `RootBoundServingSnapshot` exposes the canonical +root alongside the legacy `ServingSnapshot`, from the same store and snapshot +resolution; it does not reparse configuration or reread mutable state for this +binding. Ordinary snapshot reads do not require that extra canonicalization. +Local file roots are canonical absolute paths encoded as file URIs. A +mismatched root refuses before graph engines open. The server does not read or reinterpret a private managed identity marker. The authority supplying trust owns verification of that identity and the account/cluster/incarnation binding before deployment. In the managed @@ -230,16 +232,32 @@ until expiry or trust retirement, and local clearing is not server revocation. Automation may use the explicit origin-bound control credential to mint into the same keychain; unattended raw-token consumers use the issuance API. -With managed context in the exact current directory, `query` and `mutate` -require `--graph` and use this cached data endpoint/credential. Other data -verbs refuse as unsupported in this increment. Missing, malformed, expired, -or under-scoped credentials never fall through to static credentials, a -profile, or direct storage. Managed data access rejects explicit `--server`, -`--profile`, `--store`, `--cluster`, and `--as` and ignores inherited legacy -profiles and token settings. `--direct` becomes a global explicit override; -existing `cluster --direct` remains compatible. Without context, legacy data -commands keep their behavior. Managed requests refuse redirects and have a -10-second deadline and 8 MiB response bound. +Only implicitly addressed `query` and `mutate` consult managed context in the +exact current directory. An explicit `--server`, `--profile`, `--store`, or +`--cluster` retains ordinary addressing and command-applicability validation, +without reading that context. Every other graph, storage, alias, or local +command likewise retains its existing handler. This does not add managed +transport support to those commands. Global `--direct` bypasses context as +before, including for cluster commands. + +For implicit `query`/`mutate`, absent context leaves ordinary resolution +unchanged; malformed context refuses. Valid context plus a nonempty +`OMNIGRAPH_PROFILE` or an operator `defaults.server`/`defaults.store` target +refuses with `managed_target_ambiguous` before keychain access or requests. +Even a matching-looking URL is insufficient to equate the two authentication +paths. Select the intended ordinary target explicitly, use `--direct` for +ordinary ambient resolution, or clear the competing ambient target to use +the managed folder. Merely defining profiles, presentation defaults, or a +direct actor preference does not select a target. + +An unambiguous managed request requires explicit `--graph`, rejects explicit +`--as`, and uses the separately cached data endpoint/credential. Missing, +malformed, expired, or under-scoped credentials never fall through to static +credentials, a profile, or direct storage. Legacy token settings never supply +managed authority. Managed requests refuse redirects and have a 10-second +deadline and 8 MiB response bound. Cluster control dispatch and token issuance +retain their existing scope requirements. New named managed connections are +a separate change. ## Invariants @@ -260,6 +278,18 @@ not leave a token-only deployment in unauthenticated mode after rollback. No graph storage, ledger, manifest, policy-file, or managed-marker format changes. The public API gains authentication semantics, not a new graph route. +The public `ServerConfig`, `ServingSnapshot`, and `ResolvedActor` retain their +pre-data-token field shapes, including literal construction and exhaustive +destructuring. Managed embedders use additive root-bound snapshot and server +boot entry points. `ResolvedActor` is an identity projection; an opaque +`AuthenticatedActor` retains verified signed claims and the selected graph +through authorization. The existing verifier's identity-only return remains +available; server middleware uses its authenticated-result counterpart. +Constructing a public identity, including a signed-looking scope, cannot +manufacture authenticated request authority. Callers that adopted the interim +root/trust fields migrate to the additive managed APIs described in the +[developer guide](../dev/control-plane.md#public-embedding-apis). + ## Alternatives - Online introspection or JWKS fetching adds a serving dependency and cannot @@ -332,6 +362,25 @@ qualification remain separate from acceptance. ## Decision log +2026-09-06: The compatibility repair restores the three public struct shapes +and isolates canonical-root validation in opt-in managed boot. This replaces +the Boot trust sentence naming `ServingSnapshot.canonical_root`; root binding +still comes from the same opened store and must validate before graph open. +An opaque authenticated-request value preserves signed authorization ceilings +while the public actor remains constructible. Existing CLI trust flags and +wire contracts remain unchanged; the additive APIs replace interim field-based +embedding without claiming compatibility with both conflicting literal shapes. + +2026-09-06: Authorized a bounded compatibility repair independently of the +broader configuration/CLI proposal. This amendment replaces the CLI paragraph +that refused other data verbs and explicit ordinary selectors in managed +folders, and the sentence that ignored inherited profiles. Context now gates +only implicit query/mutate; competing ambient targets refuse before credential +access rather than silently selecting either destination. Existing unambiguous +managed access, offline verification, action grants and no-fallback guarantees +remain. Implementation and regression evidence for this correction must be +reported separately from the earlier pilot qualification. + 2026-09-05: Recorded the bounded offline verifier before implementation, following the existing server policy/action audit. Managed root identity is validated by the trust supplier; the OSS server binds that supplied trust to diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index e17dc3a6f..204a454e5 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -46,13 +46,31 @@ signing trust is retired. Each request refuses redirects, has a 10-second deadline, and accepts at most 8 MiB of response data. Missing, malformed, expired, or insufficient cached authority refuses before -a request. In a managed folder, other data commands, aliases, and storage -maintenance are unsupported. Explicit `--server`, `--profile`, `--store`, -`--cluster`, and `--as` refuse. -Inherited profiles and legacy token settings are ignored. Use global -`--direct` to explicitly select legacy addressing and credentials, including -when the context is malformed. Existing `cluster --direct` remains valid. -Without managed context, existing data commands retain their behavior. +a request. An explicit `--server`, `--profile`, `--store`, or `--cluster` +selects ordinary addressing and follows that command's existing support +rules, even in a managed folder or beside malformed context. Other data +commands, aliases, and storage maintenance also keep their ordinary behavior; +this does not give them managed credentials. Explicit `--as` alone is not a +target and remains prohibited on managed requests. + +Implicit `query`/`mutate` refuses with `managed_target_ambiguous` when valid +folder context competes with `OMNIGRAPH_PROFILE` or an operator default server +or store. No credential is read and neither destination is contacted. Choose +the ordinary target explicitly, or use `--direct` to select ordinary ambient +resolution. To use the managed folder, unset the environment profile and +remove the competing default target, using a separate operator home if needed. +Presentation defaults and profiles that are merely defined do not conflict. + +For example, this keeps using staging from a folder bound to production: + +```bash +omnigraph query find_person --profile staging --graph knowledge --json +``` + +Legacy token settings never supply managed authority. Global `--direct` +continues to select ordinary addressing and credentials, including when the +context is malformed. Existing `cluster --direct` remains valid. Without +managed context, existing data commands retain their behavior. `cluster token --clear [--config DIR]` forgets that cluster's local data entry, independently of the control-plane session. Do not combine `--clear` diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index a0aa24da6..56f4b6ead 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -254,8 +254,8 @@ cluster: CLUSTER_ID api: https://control.example ``` -The context contains no secret and is read only from the selected `--config` -directory, which defaults to `.`. Parent directories are not searched. +The context contains no secret. Cluster commands read it only from the selected +`--config` directory, which defaults to `.`. Parent directories are not searched. Unknown fields, versions, malformed files, symbolic links, and files over 16 KiB are refused. API addresses must be origins without credentials, path, query, or fragment. HTTPS is required except for exact localhost,