diff --git a/Cargo.lock b/Cargo.lock index 181e6f6bc..5acc23095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,6 +1097,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1798,6 +1804,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2720,6 +2738,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "educe" version = "0.6.0" @@ -2738,6 +2770,26 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "ena" version = "0.14.4" @@ -2897,6 +2949,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3150,6 +3212,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3235,6 +3298,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -5591,6 +5665,7 @@ dependencies = [ "aws-config", "aws-sdk-secretsmanager", "axum", + "base64 0.22.1", "clap", "color-eyre", "dashmap", @@ -5598,11 +5673,13 @@ dependencies = [ "headers", "lance", "lance-index", + "libc", "omnigraph-api-types", "omnigraph-cluster", "omnigraph-compiler", "omnigraph-engine", "omnigraph-policy", + "p256", "regex", "serde", "serde_json", @@ -6008,6 +6085,18 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -6392,6 +6481,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -7062,6 +7160,16 @@ dependencies = [ "tower-service", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -7369,6 +7477,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index a91079137..08e551874 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -22,8 +22,14 @@ repair, cleanup, schema plan, lint).\n \ control — manage or inspect a cluster (cluster via --config; policy & queries via \ --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\ 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. + #[arg(long, global = true)] + pub(crate) direct: bool, + /// Actor id for direct-engine writes and actor-bound cluster operations; /// overrides `operator.actor`. No effect on remote writes (the server /// resolves the actor from the bearer token). With a policy configured @@ -41,7 +47,7 @@ pub(crate) struct Cli { /// Select a graph within a multi-graph scope: on a `--server` it appends /// `/graphs/` to the server url; on `--cluster` it picks which cluster /// graph to maintain. Rejected on a single-graph address (a positional URI / - /// `--store`). + /// `--store`). Required for managed data queries, mutations, and token issuance. #[arg(long, global = true, value_name = "GRAPH_ID")] pub(crate) graph: Option, @@ -364,11 +370,8 @@ pub(crate) enum Command { }, // ── Control plane ── manage a cluster directory (--config ). - /// Validate and plan read-only cluster configuration. + /// Manage cluster configuration or the folder's selected managed cluster. Cluster { - /// Explicitly use the direct Core path, ignoring folder context. - #[arg(long, global = true)] - direct: bool, #[command(subcommand)] command: ClusterCommand, }, @@ -518,6 +521,22 @@ pub(crate) enum BlobCommand { #[derive(Debug, Subcommand)] pub(crate) enum ClusterCommand { + /// Cache a scoped data credential for this managed cluster, or forget it locally. + Token { + #[arg(long, default_value = ".")] + config: PathBuf, + #[arg(long)] + json: bool, + /// Comma-separated data actions, such as read,change. + #[arg(long, required_unless_present = "clear", conflicts_with = "clear")] + actions: Option, + /// Credential lifetime, 60 seconds to 24 hours (default 1h). + #[arg(long, value_parser = crate::managed::data::parse_ttl, conflicts_with = "clear")] + ttl: Option, + /// Forget this cluster's cached data credential; does not revoke it at the server. + #[arg(long)] + clear: bool, + }, /// Validate cluster.yaml and referenced schemas, queries, and policy files. Validate { /// Cluster config directory containing cluster.yaml. diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index d325bb832..ed6bd457b 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -51,8 +51,8 @@ use crate::cli::CliLoadMode; use crate::helpers::{ apply_bearer_token, apply_server_flag, build_blob_http_client, build_http_client, is_remote_uri, legacy_change_request_body, precondition_failed_cli, query_params_from_json, - remote_json, remote_json_with_graph_commit_precondition, remote_url, resolve_cli_actor, - resolve_cli_graph, resolve_remote_bearer_token, resolve_server_flag, select_named_query, + remote_json, remote_json_bounded, remote_url, resolve_cli_actor, resolve_cli_graph, + resolve_remote_bearer_token, resolve_server_flag, select_named_query, }; use crate::output::{LoadOutput, load_output_from_graph_batch, load_output_from_receipt}; @@ -68,6 +68,7 @@ pub(crate) enum GraphClient { http: reqwest::Client, base_url: String, token: Option, + response_limit: Option, }, } @@ -112,6 +113,20 @@ fn reject_positional_remote(via_server: bool, uri: &str) -> Result<()> { } impl GraphClient { + /// An already validated managed credential never enters legacy scope or token resolution. + pub(crate) fn managed(endpoint: &str, graph: &str, token: String) -> Result { + Ok(Self::Remote { + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(10)) + .build()?, + base_url: remote_url(endpoint, &["graphs", graph], &[])?, + token: Some(token), + response_limit: Some(8 * 1024 * 1024), + }) + } + /// The single owner of registry (`GET /graphs`) addressing: the bare base /// URL of `server` (a config name or literal URL) — never `/graphs/` /// — with the keyed bearer-token chain. Synchronous: pure config @@ -124,6 +139,7 @@ impl GraphClient { http: build_http_client()?, base_url: base, token, + response_limit: None, }) } @@ -211,6 +227,7 @@ impl GraphClient { http: build_http_client()?, base_url: uri, token, + response_limit: None, }) } else { Ok(GraphClient::Embedded { uri, actor: None }) @@ -275,6 +292,7 @@ impl GraphClient { http: build_http_client()?, base_url: resolved.uri, token, + response_limit: None, }) } else { let actor = resolve_cli_actor(cli_as)?; @@ -310,6 +328,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -335,6 +354,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -363,6 +383,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -388,6 +409,7 @@ impl GraphClient { http, base_url, token, + .. } => { let url = match branch { Some(branch) => remote_url(base_url, &["commits"], &[("branch", branch)])?, @@ -414,6 +436,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -446,6 +469,7 @@ impl GraphClient { http, base_url, token, + .. } => { let limit_value = limit.map(|limit| limit.to_string()); let mut query = Vec::new(); @@ -503,6 +527,7 @@ impl GraphClient { http, base_url, token, + .. } => { let limit_value = limit.map(|limit| limit.to_string()); let mut query = Vec::new(); @@ -577,6 +602,7 @@ impl GraphClient { http, base_url, token, + .. } => { let request = apply_bearer_token( http.request( @@ -656,6 +682,7 @@ impl GraphClient { http, base_url, token, + .. } => { let data = std::fs::read_to_string(data)?; let mut query = vec![("branch", branch), ("mode", mode.as_str())]; @@ -726,6 +753,7 @@ impl GraphClient { http, base_url, token, + .. } => { let data = std::fs::read_to_string(data)?; remote_json( @@ -778,6 +806,7 @@ impl GraphClient { http, base_url, token, + response_limit, } => { let (url, body) = if expected_head.is_some() { ( @@ -795,13 +824,14 @@ impl GraphClient { legacy_change_request_body(query_source, query_name, branch, params_json), ) }; - remote_json_with_graph_commit_precondition( + remote_json_bounded( http, Method::POST, url, Some(body), token.as_deref(), expected_head, + *response_limit, ) .await } @@ -858,12 +888,13 @@ impl GraphClient { http, base_url, token, + response_limit, } => { let (branch, snapshot) = match &target { ReadTarget::Branch(branch) => (Some(branch.clone()), None), ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())), }; - remote_json( + remote_json_bounded( http, Method::POST, remote_url(base_url, &["query"], &[])?, @@ -875,6 +906,8 @@ impl GraphClient { snapshot, })?), token.as_deref(), + None, + *response_limit, ) .await } @@ -916,6 +949,7 @@ impl GraphClient { http, base_url, token, + response_limit, } => { let body = InvokeStoredQueryRequest { params: params_json.cloned(), @@ -923,7 +957,7 @@ impl GraphClient { snapshot, expect_mutation: Some(expect_mutation), }; - remote_json_with_graph_commit_precondition( + remote_json_bounded( http, Method::POST, if expected_head.is_some() { @@ -934,6 +968,7 @@ impl GraphClient { Some(serde_json::to_value(body)?), token.as_deref(), expected_head, + *response_limit, ) .await } @@ -955,6 +990,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -989,6 +1025,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -1026,6 +1063,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, @@ -1092,6 +1130,7 @@ impl GraphClient { http, base_url, token, + .. } => { // MR-694 PR B: SchemaApplyRequest carries allow_data_loss so // Hard-mode drops are no longer CLI-only; the server's @@ -1141,6 +1180,7 @@ impl GraphClient { http, base_url, token, + .. } => { let request = apply_bearer_token( http.request(Method::POST, remote_url(base_url, &["export"], &[])?), @@ -1350,6 +1390,7 @@ impl GraphClient { http, base_url, token, + .. } => { remote_json( http, diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index ca9a9e7ca..1156590a5 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -502,6 +502,28 @@ pub(crate) async fn remote_json_with_graph_commit_precondition, bearer_token: Option<&str>, expected_commit: Option<&str>, +) -> Result { + remote_json_bounded( + client, + method, + url, + body, + bearer_token, + expected_commit, + None, + ) + .await +} + +/// Same typed graph protocol with an optional response limit for managed data access. +pub(crate) async fn remote_json_bounded( + client: &reqwest::Client, + method: Method, + url: String, + body: Option, + bearer_token: Option<&str>, + expected_commit: Option<&str>, + response_limit: Option, ) -> Result { let request = apply_bearer_token(client.request(method, url), bearer_token); let request = if let Some(commit_id) = expected_commit { @@ -517,10 +539,33 @@ pub(crate) async fn remote_json_with_graph_commit_precondition limit as u64) { + bail!("managed data response exceeds 8 MiB"); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if chunk.len() > limit.saturating_sub(bytes.len()) { + bail!("managed data response exceeds 8 MiB"); + } + bytes.extend_from_slice(&chunk); + } + String::from_utf8(bytes)? + } else { + response.text().await? + }; if !status.is_success() { + let text = match (response_limit, bearer_token) { + (Some(_), Some(token)) if !token.is_empty() => { + crate::managed::scrub_response_error(text, token) + } + _ => text, + }; if let Ok(error) = serde_json::from_str::(&text) { if error.precondition_failure.is_some() { return Err(PreconditionFailedCli { output: error }.into()); @@ -529,7 +574,13 @@ pub(crate) async fn remote_json_with_graph_commit_precondition Result<()> { } return Ok(()); } + let managed_data = match managed::data::client(&cli) { + Ok(client) => client, + Err(output) => { + let code = output.emit()?; + std::process::exit(code); + } + }; let http_client = build_http_client()?; // RFC-010 Slice 1: reject scope-addressing flags a verb can't consume, // from one declared flag × capability matrix — before any per-command @@ -1118,15 +1125,19 @@ async fn main() -> Result<()> { format, json, } => { - let client = client::GraphClient::resolve( - capability, - cli.server.as_deref(), - cli.graph.as_deref(), - None, - cli.profile.as_deref(), - cli.store.as_deref(), - ) - .await?; + let client = if let Some(client) = managed_data { + client + } else { + client::GraphClient::resolve( + capability, + cli.server.as_deref(), + cli.graph.as_deref(), + None, + cli.profile.as_deref(), + cli.store.as_deref(), + ) + .await? + }; let params_json = load_params_json(¶ms)?; let target = resolve_read_target(branch, snapshot, None)?; let output: ReadOutput = if query.is_some() || query_string.is_some() { @@ -1165,16 +1176,20 @@ async fn main() -> Result<()> { if_commit, json, } => { - let client = client::GraphClient::resolve_with_policy( - capability, - cli.server.as_deref(), - cli.graph.as_deref(), - None, - cli.as_actor.as_deref(), - cli.profile.as_deref(), - cli.store.as_deref(), - ) - .await?; + let client = if let Some(client) = managed_data { + client + } else { + client::GraphClient::resolve_with_policy( + capability, + cli.server.as_deref(), + cli.graph.as_deref(), + None, + cli.as_actor.as_deref(), + cli.profile.as_deref(), + cli.store.as_deref(), + ) + .await? + }; let params_json = load_params_json(¶ms)?; let branch = resolve_branch(branch, None, "main"); let result: Result = if query.is_some() || query_string.is_some() { @@ -1662,7 +1677,9 @@ async fn main() -> Result<()> { let output = force_unlock_config_dir(config, lock_id).await; finish_cluster_force_unlock(&output, json)?; } - ClusterCommand::History { .. } | ClusterCommand::Cancel { .. } => { + ClusterCommand::History { .. } + | ClusterCommand::Cancel { .. } + | ClusterCommand::Token { .. } => { unreachable!("managed dispatch refuses managed-only verbs without context") } }, diff --git a/crates/omnigraph-cli/src/managed.rs b/crates/omnigraph-cli/src/managed.rs index 2009b4d49..d8057f99f 100644 --- a/crates/omnigraph-cli/src/managed.rs +++ b/crates/omnigraph-cli/src/managed.rs @@ -1,5 +1,5 @@ -//! RFC 0052: the Intent API is the only authority on this path. Context is -//! routing, never a cached ledger, and no error here may dispatch to Core. +//! Managed control routing (RFC 0052) and offline data credentials (RFC 0053). +//! Context selects authority, never caches a ledger; errors never fall back to Core. use crate::cli::{Cli, ClusterCommand, Command, ManagedRunArgs}; use reqwest::{Client, Method, StatusCode}; @@ -12,6 +12,19 @@ use tokio::time::Instant; use url::Url; mod auth; +pub(crate) mod data; + +/// Managed transports must not render an endpoint's reflected credential. +pub(crate) fn scrub_response_error(text: String, secret: &str) -> String { + let text = if let Ok(mut value) = serde_json::from_str::(&text) { + auth::scrub_value(&mut value, secret); + value.to_string() + } else { + text + }; + // JSON object keys can reflect credentials too; normalization decodes escapes first. + text.replace(secret, "[redacted]") +} const MAX_CONTEXT: u64 = 16 * 1024; const MAX_BODY: usize = 8 * 1024 * 1024; @@ -455,7 +468,9 @@ fn managed_flags(command: &ClusterCommand) -> bool { } => revision.is_some() || opts(managed), ClusterCommand::Apply { plan, managed, .. } => plan.is_some() || opts(managed), ClusterCommand::Status { run_id, .. } => run_id.is_some(), - ClusterCommand::History { .. } | ClusterCommand::Cancel { .. } => true, + ClusterCommand::History { .. } + | ClusterCommand::Cancel { .. } + | ClusterCommand::Token { .. } => true, _ => false, } } @@ -472,7 +487,8 @@ fn config_and_json(command: &ClusterCommand) -> (&Path, bool) { | ClusterCommand::Import { config, json } | ClusterCommand::ForceUnlock { config, json, .. } | ClusterCommand::History { config, json, .. } - | ClusterCommand::Cancel { config, json, .. } => (config, *json), + | ClusterCommand::Cancel { config, json, .. } + | ClusterCommand::Token { config, json, .. } => (config, *json), } } @@ -497,6 +513,17 @@ async fn cluster_command( context: &Context, command: &ClusterCommand, ) -> Result<(Value, i32)> { + if let ClusterCommand::Token { + actions, + ttl, + clear, + .. + } = command + { + return data::token(cli, context, actions.as_deref(), *ttl, *clear) + .await + .map(|body| (body, 0)); + } reject_scope(cli)?; // Reject unsupported verbs and invalid requests before accessing credentials. match command { @@ -526,7 +553,7 @@ async fn cluster_command( } let api = Api::new( context.api.clone(), - Some(auth::credential(&auth::OsStore, &context.api)?), + Some(auth::credential(&auth::CONTROL_STORE, &context.api)?), )?; let base = format!("/v1/clusters/{}", context.cluster); match command { @@ -644,7 +671,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { .. } => { let result = match reject_scope(cli).and_then(|()| canonical_origin(api)) { - Ok(origin) => auth::login(&auth::OsStore, origin).await, + Ok(origin) => auth::login(&auth::CONTROL_STORE, origin).await, Err(err) => Err(err), }; Some(Output::from_result(result, *json, 0)) @@ -655,7 +682,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { .. } => { let result = match reject_scope(cli).and_then(|()| canonical_origin(api)) { - Ok(origin) => auth::logout(&auth::OsStore, origin).await, + Ok(origin) => auth::logout(&auth::CONTROL_STORE, origin).await, Err(err) => Err(err), }; Some(Output::from_result(result, *json, 0)) @@ -673,7 +700,7 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { let origin = canonical_origin(api)?; let client = Api::new( origin.clone(), - Some(auth::credential(&auth::OsStore, &origin)?), + Some(auth::credential(&auth::CONTROL_STORE, &origin)?), )?; let body = client .request( @@ -700,9 +727,9 @@ pub(crate) async fn dispatch(cli: &Cli) -> Option { .await; Some(Output::from_result(result, *json, 0)) } - Command::Cluster { command, direct } => { + Command::Cluster { command } => { let (config, json) = config_and_json(command); - let context = if *direct { + let context = if cli.direct { Ok(None) } else { read_context(config) diff --git a/crates/omnigraph-cli/src/managed/auth.rs b/crates/omnigraph-cli/src/managed/auth.rs index 426d8d60a..e5530531f 100644 --- a/crates/omnigraph-cli/src/managed/auth.rs +++ b/crates/omnigraph-cli/src/managed/auth.rs @@ -5,16 +5,24 @@ use std::time::Duration; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::time::Instant; -const SERVICE: &str = "omnigraph.control-plane.session.v1"; const MAX_SECRET: usize = 16 * 1024; -trait Store { +pub(super) trait Store { fn get(&self, origin: &str) -> Result>; fn put(&self, origin: &str, value: &str) -> Result<()>; fn remove(&self, origin: &str) -> Result<()>; } -pub(super) struct OsStore; +pub(super) struct OsStore { + service: &'static str, +} + +pub(super) const CONTROL_STORE: OsStore = OsStore { + service: "omnigraph.control-plane.session.v1", +}; +pub(super) const DATA_STORE: OsStore = OsStore { + service: "omnigraph.data-plane.credential.v1", +}; fn keychain_failed() -> Failure { // Some keyring errors contain raw secret bytes: never render the error. @@ -34,7 +42,7 @@ fn keychain_failed() -> Failure { ))] impl Store for OsStore { fn get(&self, origin: &str) -> Result> { - let entry = keyring::Entry::new(SERVICE, origin).map_err(|_| keychain_failed())?; + let entry = keyring::Entry::new(self.service, origin).map_err(|_| keychain_failed())?; match entry.get_password() { Ok(value) => Ok(Some(value)), Err(keyring::Error::NoEntry) => Ok(None), @@ -42,12 +50,12 @@ impl Store for OsStore { } } fn put(&self, origin: &str, value: &str) -> Result<()> { - keyring::Entry::new(SERVICE, origin) + keyring::Entry::new(self.service, origin) .and_then(|entry| entry.set_password(value)) .map_err(|_| keychain_failed()) } fn remove(&self, origin: &str) -> Result<()> { - let entry = keyring::Entry::new(SERVICE, origin).map_err(|_| keychain_failed())?; + let entry = keyring::Entry::new(self.service, origin).map_err(|_| keychain_failed())?; match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(_) => Err(keychain_failed()), @@ -214,7 +222,7 @@ fn scrub(mut failure: Failure, secret: &str) -> Failure { failure } -fn scrub_value(value: &mut Value, secret: &str) { +pub(super) fn scrub_value(value: &mut Value, secret: &str) { match value { Value::String(s) => *s = s.replace(secret, "[redacted]"), Value::Array(items) => { @@ -414,4 +422,4 @@ pub(super) async fn logout(store: &OsStore, origin: String) -> Result { } #[cfg(test)] -mod tests; +pub(super) mod tests; diff --git a/crates/omnigraph-cli/src/managed/auth/tests.rs b/crates/omnigraph-cli/src/managed/auth/tests.rs index c155b834e..44672b722 100644 --- a/crates/omnigraph-cli/src/managed/auth/tests.rs +++ b/crates/omnigraph-cli/src/managed/auth/tests.rs @@ -4,7 +4,7 @@ use std::cell::RefCell; use std::collections::HashMap; #[derive(Default)] -struct MemoryStore(RefCell>); +pub(in crate::managed) struct MemoryStore(RefCell>); impl Store for MemoryStore { fn get(&self, origin: &str) -> Result> { Ok(self.0.borrow().get(origin).cloned()) diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs new file mode 100644 index 000000000..a86c28fe9 --- /dev/null +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -0,0 +1,382 @@ +//! RFC 0053: cached data authority has its own keychain namespace and transport. +use super::auth::{self, Store}; +use super::{Api, Context, Failure, Method, Output, Result, canonical_origin, json}; +use crate::cli::{Cli, Command}; +use crate::client::GraphClient; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +const MAX_CREDENTIAL: usize = 64 * 1024; +const MAX_TOKEN: usize = 8192; +const ACTIONS: [&str; 8] = [ + "read", + "export", + "change", + "branch_create", + "branch_delete", + "branch_merge", + "invoke_query", + "graph_list", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct Grant { + graph_id: String, + actions: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Credential { + version: u8, + api: String, + cluster_id: String, + endpoint: String, + token: String, + expires_at: String, + kid: String, + actor: String, + grants: Vec, +} + +fn key(context: &Context) -> String { + format!("{}/clusters/{}", context.api, context.cluster) +} + +fn invalid() -> Failure { + Failure::refused( + "data_credential_invalid", + "the cached data credential is invalid; mint a new cluster token", + ) +} + +fn graph_id(graph: &str) -> Result<()> { + // The server's graph selector is a single path segment, never path syntax. + if graph.is_empty() + || graph.len() > 64 + || !graph.as_bytes()[0].is_ascii_alphabetic() + || !graph + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + || matches!(graph, "policies" | "healthz" | "openapi" | "graphs") + { + return Err(Failure::refused( + "graph_invalid", + "--graph must select one valid graph id", + )); + } + Ok(()) +} + +fn validate_grants(grants: &[Grant]) -> Result<()> { + let mut graphs = BTreeSet::new(); + if grants.is_empty() || grants.len() > 64 { + return Err(invalid()); + } + for grant in grants { + graph_id(&grant.graph_id).map_err(|_| invalid())?; + let mut seen = BTreeSet::new(); + if !graphs.insert(&grant.graph_id) + || grant.actions.is_empty() + || grant.actions.len() > ACTIONS.len() + || grant + .actions + .iter() + .any(|a| !ACTIONS.contains(&a.as_str()) || !seen.insert(a)) + { + return Err(invalid()); + } + } + Ok(()) +} + +impl Credential { + fn validate(&self, context: &Context) -> Result<()> { + let now = OffsetDateTime::now_utc(); + let expires = OffsetDateTime::parse(&self.expires_at, &Rfc3339).map_err(|_| invalid())?; + if self.version != 1 + || self.api != context.api + || self.cluster_id != context.cluster + || !canonical_origin(&self.endpoint).is_ok_and(|o| o == self.endpoint) + || self.token.is_empty() + || self.token.len() > MAX_TOKEN + || self.token.split('.').count() != 3 + || self.token.split('.').any(str::is_empty) + || !self + .token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + || self.kid.len() != 64 + || !self + .kid + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || !self.actor.starts_with("principal:") + || self.actor.len() == "principal:".len() + || self.actor.len() > 1024 + || self + .actor + .chars() + .any(|c| c.is_control() || c.is_whitespace()) + || expires > now + time::Duration::seconds(86430) + { + return Err(invalid()); + } + if expires <= now { + return Err(Failure::refused( + "data_credential_expired", + "the data credential has expired; mint a new cluster token", + )); + } + validate_grants(&self.grants) + } + + fn metadata(&self) -> Value { + json!({"cluster_id":self.cluster_id,"endpoint":self.endpoint,"expires_at":self.expires_at,"kid":self.kid,"actor":self.actor,"grants":self.grants}) + } +} + +pub(crate) fn parse_ttl(input: &str) -> std::result::Result { + let (number, multiplier) = match input.as_bytes().last() { + Some(b's') => (&input[..input.len() - 1], 1), + Some(b'm') => (&input[..input.len() - 1], 60), + Some(b'h') => (&input[..input.len() - 1], 3600), + Some(b'd') => (&input[..input.len() - 1], 86400), + _ => (input, 1), + }; + number + .parse::() + .ok() + .and_then(|n| n.checked_mul(multiplier)) + .filter(|n| (60..=86400).contains(n)) + .ok_or_else(|| { + "TTL must be 60–86400 seconds, optionally suffixed s, m, h, or d".to_string() + }) +} + +fn scope(cli: &Cli) -> Result<()> { + if cli.server.is_some() + || cli.profile.is_some() + || cli.store.is_some() + || cli.cluster.is_some() + || cli.as_actor.is_some() + { + 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", + )); + } + Ok(()) +} + +fn requested_grant(graph: Option<&str>, actions: Option<&str>) -> Result { + let graph = + graph.ok_or_else(|| Failure::refused("graph_required", "managed data requires --graph"))?; + graph_id(graph)?; + let actions: Vec = actions + .unwrap_or("") + .split(',') + .map(str::to_string) + .collect(); + let grant = Grant { + graph_id: graph.into(), + actions, + }; + validate_grants(std::slice::from_ref(&grant)).map_err(|_| Failure::refused("data_actions_invalid", "--actions must be a nonempty, duplicate-free comma-separated list of supported data actions"))?; + Ok(grant) +} + +async fn mint( + store: &impl Store, + context: &Context, + api: &Api, + grant: Grant, + ttl: u64, +) -> Result { + // Fail early if the platform cannot access its credential store. + let _ = store.get(&key(context))?; + let body = api + .request( + Method::POST, + &format!("/v1/clusters/{}/tokens", context.cluster), + Some(&json!({"grants":[grant],"ttl_seconds":ttl})), + None, + ) + .await?; + super::cluster_matches(&body, &context.cluster)?; + let data = &body["data"]; + let string = |field| { + data.get(field) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(Failure::protocol) + }; + let credential = Credential { + version: 1, + api: context.api.clone(), + cluster_id: context.cluster.clone(), + endpoint: string("endpoint")?, + token: string("token")?, + expires_at: string("expires_at")?, + kid: string("kid")?, + actor: string("actor")?, + grants: serde_json::from_value(data["grants"].clone()).map_err(|_| Failure::protocol())?, + }; + credential.validate(context)?; + if credential.grants.len() != 1 + || credential.grants[0].graph_id != grant.graph_id + || credential.grants[0].actions.iter().collect::>() + != grant.actions.iter().collect::>() + || OffsetDateTime::parse(&credential.expires_at, &Rfc3339).map_err(|_| invalid())? + > OffsetDateTime::now_utc() + time::Duration::seconds(ttl as i64 + 30) + { + return Err(Failure::protocol()); + } + let saved = serde_json::to_string(&credential).map_err(|_| invalid())?; + if saved.len() > MAX_CREDENTIAL { + return Err(invalid()); + } + store.put(&key(context), &saved)?; + // Construct output from a strict metadata allowlist; never echo provider/API extras. + let mut metadata = credential.metadata(); + auth::scrub_value(&mut metadata, &credential.token); + Ok(json!({"data":metadata,"meta":{"cluster_id":context.cluster}})) +} + +fn clear(store: &impl Store, context: &Context) -> Result { + store.remove(&key(context))?; + Ok( + json!({"data":{"cluster_id":context.cluster,"local_credential_removed":true,"revocation_performed":false},"meta":{"cluster_id":context.cluster}}), + ) +} + +pub(super) async fn token( + cli: &Cli, + context: &Context, + actions: Option<&str>, + ttl: Option, + clear: bool, +) -> Result { + scope(cli)?; + if clear { + if cli.graph.is_some() || actions.is_some() || ttl.is_some() { + return Err(Failure::refused( + "token_clear_conflict", + "--clear forgets the whole cached cluster credential and cannot select a graph, actions, or TTL", + )); + } + return self::clear(&auth::DATA_STORE, context); + } + let grant = requested_grant(cli.graph.as_deref(), actions)?; + let api = Api::new( + context.api.clone(), + Some(auth::credential(&auth::CONTROL_STORE, &context.api)?), + )?; + mint(&auth::DATA_STORE, context, &api, grant, ttl.unwrap_or(3600)).await +} + +fn load( + store: &impl Store, + context: &Context, + graph: &str, + required: &[&str], +) -> Result { + let raw = store.get(&key(context))?.ok_or_else(|| { + Failure::refused( + "data_credential_required", + "no data credential is cached for this cluster; run cluster token", + ) + })?; + if raw.len() > MAX_CREDENTIAL { + return Err(invalid()); + } + let credential: Credential = serde_json::from_str(&raw).map_err(|_| invalid())?; + credential.validate(context)?; + if !credential.grants.iter().any(|grant| { + grant.graph_id == graph + && required + .iter() + .all(|action| grant.actions.iter().any(|a| a == action)) + }) { + return Err(Failure::refused( + "data_scope_missing", + "the cached credential does not grant this graph and action; mint a matching cluster token", + )); + } + GraphClient::managed(&credential.endpoint, graph, credential.token).map_err(|_| { + Failure::new( + "transport_failed", + "could not initialize the managed data client", + 1, + ) + }) +} + +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 { .. })) +} + +fn resolve(cli: &Cli, config: &std::path::Path, store: &impl Store) -> Result> { + if skips_context(cli) { + return Ok(None); + } + let Some(context) = super::read_context(config)? else { + return Ok(None); + }; + let (action, named) = match &cli.command { + Command::Query { + query, + query_string, + .. + } => ("read", query.is_none() && query_string.is_none()), + Command::Mutate { + query, + query_string, + .. + } => ("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", + )); + } + }; + scope(cli)?; + let graph = cli + .graph + .as_deref() + .ok_or_else(|| Failure::refused("graph_required", "managed data requires --graph"))?; + graph_id(graph)?; + let required = if named { + vec![action, "invoke_query"] + } else { + vec![action] + }; + load(store, &context, graph, &required).map(Some) +} + +pub(crate) fn client(cli: &Cli) -> std::result::Result, Output> { + if skips_context(cli) { + return Ok(None); + } + let json = match &cli.command { + Command::Query { json, format, .. } => { + *json || matches!(format, Some(crate::read_format::ReadOutputFormat::Json)) + } + Command::Mutate { json, .. } => *json, + _ => false, + }; + 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)); + result.map_err(|e| Output::from_result(Err(e), json, 2)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs new file mode 100644 index 000000000..59c63996a --- /dev/null +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -0,0 +1,501 @@ +use super::*; +use crate::managed::auth::tests::MemoryStore; +use crate::managed_http_fixture::{IntentApiFixture, IntentReply}; +use clap::Parser; +use omnigraph::db::ReadTarget; + +const DATA_TOKEN: &str = "header.payload.signature"; + +fn context() -> Context { + Context { + version: 1, + cluster: "cluster-a".into(), + api: "https://control.example".into(), + } +} + +fn credential(context: &Context, endpoint: &str) -> Credential { + Credential { + version: 1, + api: context.api.clone(), + cluster_id: context.cluster.clone(), + endpoint: endpoint.into(), + token: DATA_TOKEN.into(), + expires_at: (OffsetDateTime::now_utc() + time::Duration::hours(1)) + .format(&Rfc3339) + .unwrap(), + kid: "a".repeat(64), + actor: "principal:alice".into(), + grants: vec![Grant { + graph_id: "knowledge".into(), + actions: vec!["read".into(), "change".into(), "invoke_query".into()], + }], + } +} + +fn save(store: &MemoryStore, context: &Context, credential: &Credential) { + store + .put(&key(context), &serde_json::to_string(credential).unwrap()) + .unwrap(); +} + +fn read_reply() -> Value { + json!({"query_name":"q","target":{"branch":"main"},"row_count":1,"columns":["value"],"rows":[{"value":42}],"graph_commit_id":"head-a"}) +} + +fn change_reply() -> Value { + json!({"branch":"main","query_name":"m","affected_nodes":1,"affected_edges":0,"actor_id":"principal:alice","commit":null}) +} + +#[test] +fn token_arguments_bound_authority_and_keep_direct_compatibility() { + for (input, expected) in [ + ("60", 60), + ("1m", 60), + ("1h", 3600), + ("24h", 86400), + ("1d", 86400), + ] { + assert_eq!(parse_ttl(input).unwrap(), expected); + } + for bad in [ + "0", + "59s", + "25h", + "999999999999999999999999h", + "-1h", + "1.5h", + "", + ] { + assert!(parse_ttl(bad).is_err()); + } + for bad in ["", "read,read", "read,admin", "schema_apply", "*", " read"] { + assert!(requested_grant(Some("knowledge"), Some(bad)).is_err()); + } + for bad in [ + "../graph", + "graph_bad", + "policies", + "graphs", + "κnowledge", + "1graph", + "-graph", + ] { + assert!(requested_grant(Some(bad), Some("read")).is_err()); + } + assert!(Cli::try_parse_from(["omnigraph", "cluster", "token", "--clear"]).is_ok()); + assert!( + Cli::try_parse_from([ + "omnigraph", + "cluster", + "token", + "--clear", + "--actions", + "read" + ]) + .is_err() + ); + assert!( + Cli::try_parse_from(["omnigraph", "cluster", "status", "--direct"]) + .unwrap() + .direct + ); + assert!( + Cli::try_parse_from([ + "omnigraph", + "query", + "q", + "--direct", + "--server", + "https://data.example" + ]) + .unwrap() + .direct + ); +} + +#[tokio::test] +async fn minted_data_credential_is_separate_and_works_after_api_stops() { + let data = IntentApiFixture::new(vec![ + IntentReply::json(200, read_reply()), + IntentReply::json(200, change_reply()), + IntentReply::json(200, read_reply()), + ]); + let mut context = context(); + let mut response_credential = credential(&context, &data.origin); + response_credential.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(3629)) + .format(&Rfc3339) + .unwrap(); + let mut response = response_credential.metadata(); + response["token"] = json!(DATA_TOKEN); + response["access_token"] = json!("must-not-be-output"); + let cp = IntentApiFixture::new(vec![IntentReply::json( + 200, + json!({"data":response,"meta":{"cluster_id":context.cluster}}), + )]); + context.api = cp.origin.clone(); + let cp_store = MemoryStore::default(); + cp_store + .put(&context.api, "unrelated-control-session") + .unwrap(); + let data_store = MemoryStore::default(); + let api = Api::new(cp.origin.clone(), Some("control-session-secret".into())).unwrap(); + let output = mint( + &data_store, + &context, + &api, + response_credential.grants[0].clone(), + 3600, + ) + .await + .unwrap(); + let rendered = output.to_string(); + assert!(!rendered.contains(DATA_TOKEN)); + assert!(!rendered.contains("must-not-be-output")); + assert_eq!( + cp_store.get(&context.api).unwrap().as_deref(), + Some("unrelated-control-session") + ); + let requests = cp.requests(); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/clusters/cluster-a/tokens"); + assert_eq!( + requests[0].headers["authorization"], + "Bearer control-session-secret" + ); + assert_eq!( + requests[0].body, + json!({"grants":response_credential.grants,"ttl_seconds":3600}) + ); + cp.assert_complete(); + drop(cp); + let client = load( + &data_store, + &context, + "knowledge", + &["read", "change", "invoke_query"], + ) + .unwrap(); + let result = client + .query( + ReadTarget::Branch("main".into()), + "query q() { return { 42 as value } }", + Some("q"), + None, + ) + .await + .unwrap(); + assert_eq!(result.row_count, 1); + assert_eq!(result.rows.get(), "[{\"value\":42}]"); + let changed = client + .mutate("main", "mutation m() {}", Some("m"), None, Some("head-a")) + .await + .unwrap(); + assert_eq!(changed.actor_id.as_deref(), Some("principal:alice")); + let _: omnigraph_api_types::ReadOutput = client + .invoke_named("q", false, None, Some("main".into()), None, None) + .await + .unwrap(); + let requests = data.requests(); + assert_eq!( + requests.iter().map(|r| r.path.as_str()).collect::>(), + [ + "/graphs/knowledge/query", + "/graphs/knowledge/mutate/if-graph-commit", + "/graphs/knowledge/queries/q" + ] + ); + for request in &requests { + assert_eq!( + request.headers["authorization"], + format!("Bearer {DATA_TOKEN}") + ); + } + assert_eq!(requests[1].headers["omnigraph-if-graph-commit"], "head-a"); + data.assert_complete(); + let other = Context { + cluster: "other-cluster".into(), + ..context.clone() + }; + data_store.put(&key(&other), "other-data-entry").unwrap(); + assert_eq!( + clear(&data_store, &context).unwrap()["data"]["revocation_performed"], + false + ); + assert_eq!( + load(&data_store, &context, "knowledge", &["read"]) + .err() + .unwrap() + .body["type"], + "data_credential_required" + ); + assert!(cp_store.get(&context.api).unwrap().is_some()); + assert_eq!( + data_store.get(&key(&other)).unwrap().as_deref(), + Some("other-data-entry") + ); +} + +#[test] +fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { + let context = context(); + let store = MemoryStore::default(); + let original = credential(&context, "https://data.example"); + let mut clock_ahead = credential(&context, "https://data.example"); + clock_ahead.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(86429)) + .format(&Rfc3339) + .unwrap(); + assert!(clock_ahead.validate(&context).is_ok()); + clock_ahead.expires_at = (OffsetDateTime::now_utc() + time::Duration::seconds(86460)) + .format(&Rfc3339) + .unwrap(); + assert!(clock_ahead.validate(&context).is_err()); + save(&store, &context, &original); + assert_eq!( + load(&store, &context, "foreign", &["read"]) + .err() + .unwrap() + .body["type"], + "data_scope_missing" + ); + assert_eq!( + load(&store, &context, "knowledge", &["export"]) + .err() + .unwrap() + .body["type"], + "data_scope_missing" + ); + let base = serde_json::to_value(&original).unwrap(); + for (field, value) in [ + ("version", json!(2)), + ("api", json!("https://foreign.example")), + ("cluster_id", json!("foreign")), + ("endpoint", json!("https://data.example/path")), + ("endpoint", json!("http://data.example")), + ("endpoint", json!("https://user:secret@data.example")), + ("token", json!("a.b")), + ("token", json!("x".repeat(MAX_TOKEN + 1))), + ("kid", json!("not-a-fingerprint")), + ( + "expires_at", + json!( + (OffsetDateTime::now_utc() - time::Duration::seconds(1)) + .format(&Rfc3339) + .unwrap() + ), + ), + ( + "expires_at", + json!( + (OffsetDateTime::now_utc() + time::Duration::hours(25)) + .format(&Rfc3339) + .unwrap() + ), + ), + ("unknown", json!("authority")), + ( + "grants", + json!([{"graph_id":"knowledge","actions":["admin"]}]), + ), + ] { + let mut corrupt = base.clone(); + corrupt[field] = value; + store.put(&key(&context), &corrupt.to_string()).unwrap(); + assert!( + load(&store, &context, "knowledge", &["read"]).is_err(), + "accepted {field}" + ); + } + let duplicate = serde_json::to_string(&original) + .unwrap() + .replacen("{", "{\"version\":1,", 1); + store.put(&key(&context), &duplicate).unwrap(); + assert!(load(&store, &context, "knowledge", &["read"]).is_err()); +} + +#[tokio::test] +async fn invalid_issuance_never_replaces_cached_authority() { + for corruption in ["extra-action", "foreign-endpoint", "oversize-token"] { + let mut context = context(); + let valid = credential(&context, "https://data.example"); + let mut response = valid.metadata(); + response["token"] = json!(DATA_TOKEN); + match corruption { + "extra-action" => response["grants"][0]["actions"] = json!(["read", "export"]), + "foreign-endpoint" => { + response["endpoint"] = json!("https://user:password@data.example/path") + } + _ => response["token"] = json!("x".repeat(MAX_TOKEN + 1)), + } + let cp = IntentApiFixture::new(vec![IntentReply::json( + 200, + json!({"data":response,"meta":{"cluster_id":context.cluster}}), + )]); + context.api = cp.origin.clone(); + let store = MemoryStore::default(); + store.put(&key(&context), "prior-authority").unwrap(); + let api = Api::new(context.api.clone(), Some("control-only".into())).unwrap(); + assert!( + mint( + &store, + &context, + &api, + requested_grant(Some("knowledge"), Some("read")).unwrap(), + 3600 + ) + .await + .is_err() + ); + assert_eq!( + store.get(&key(&context)).unwrap().as_deref(), + Some("prior-authority") + ); + cp.assert_complete(); + } +} + +#[test] +fn managed_routing_refuses_scope_overrides_and_other_data_verbs_before_keychain() { + 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(); + assert_ne!(failure.body["type"], "data_credential_required"); + } + let direct = Cli::try_parse_from([ + "omnigraph", + "query", + "q", + "--direct", + "--server", + "https://legacy.example", + ]) + .unwrap(); + assert!(resolve(&direct, dir.path(), &store).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_eq!( + resolve(&query, dir.path(), &store).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()); + 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"], + "data_scope_missing" + ); +} + +#[tokio::test] +async fn managed_data_transport_refuses_redirect_and_bounds_body() { + let target = IntentApiFixture::new(vec![]); + let mut chunked = format!("{:x}\r\n", 8 * 1024 * 1024 + 1).into_bytes(); + chunked.extend(vec![b' '; 8 * 1024 * 1024 + 1]); + chunked.extend_from_slice(b"\r\n0\r\n\r\n"); + for (reply, expected) in [ + ( + IntentReply { + status: 307, + headers: vec![("Location".into(), target.origin.clone())], + body: vec![], + }, + "redirect", + ), + ( + IntentReply { + status: 200, + headers: vec![("Content-Length".into(), (8 * 1024 * 1024 + 1).to_string())], + body: vec![], + }, + "8 MiB", + ), + ( + IntentReply { + status: 200, + headers: vec![("Transfer-Encoding".into(), "chunked".into())], + body: chunked, + }, + "8 MiB", + ), + ] { + let server = IntentApiFixture::new(vec![reply]); + let client = GraphClient::managed(&server.origin, "knowledge", DATA_TOKEN.into()).unwrap(); + let error = client + .query( + ReadTarget::Branch("main".into()), + "query q() {}", + Some("q"), + None, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected), "{error}"); + server.assert_complete(); + } + target.assert_complete(); +} + +#[tokio::test] +async fn managed_data_errors_redact_reflected_credentials_including_preconditions() { + let encoded = DATA_TOKEN.replace('h', "\\u0068"); + for (status, body) in [ + (200, json!(DATA_TOKEN).to_string()), + (401, format!("{{\"error\":\"rejected {encoded}\"}}")), + (403, format!("rejected {DATA_TOKEN}")), + (403, json!({DATA_TOKEN: "rejected"}).to_string()), + (403, format!("{{\"{encoded}\":\"rejected\"}}")), + ( + 412, + json!({"error":format!("rejected {DATA_TOKEN}"),"precondition_failure":{"expected":DATA_TOKEN,"actual":null}}).to_string(), + ), + ] { + let server = IntentApiFixture::new(vec![IntentReply { + status, + headers: vec![], + body: body.into_bytes(), + }]); + let client = GraphClient::managed(&server.origin, "knowledge", DATA_TOKEN.into()).unwrap(); + let error = client + .mutate("main", "mutation m() {}", Some("m"), None, Some("head-a")) + .await + .unwrap_err(); + let rendered = if status == 412 { + serde_json::to_string( + &error.downcast_ref::().unwrap().output, + ) + .unwrap() + } else { + error.to_string() + }; + assert!(!rendered.contains(DATA_TOKEN), "credential leaked: {status}"); + if status == 200 { + assert_eq!(rendered, "invalid managed data response"); + } else { + assert!(rendered.contains("[redacted]"), "{rendered}"); + } + server.assert_complete(); + } +} diff --git a/crates/omnigraph-cli/tests/cli_cluster.rs b/crates/omnigraph-cli/tests/cli_cluster.rs index b63e71e2a..8aa851c84 100644 --- a/crates/omnigraph-cli/tests/cli_cluster.rs +++ b/crates/omnigraph-cli/tests/cli_cluster.rs @@ -56,6 +56,104 @@ fn assert_no_core_effects(root: &std::path::Path) { ); } +#[test] +fn managed_data_process_refuses_unsupported_and_conflicting_scope_before_keychain() { + let temp = tempdir().unwrap(); + let api = IntentApiFixture::new(vec![]); + write_managed_context(temp.path(), &api.origin); + for args in [ + vec!["query", "q", "--json"], + vec![ + "mutate", + "m", + "--graph", + "knowledge", + "--as", + "forged", + "--json", + ], + vec![ + "query", + "q", + "--graph", + "knowledge", + "--server", + "https://foreign.example", + "--json", + ], + vec!["cluster", "token", "--actions", "read", "--json"], + vec![ + "cluster", + "token", + "--clear", + "--graph", + "knowledge", + "--json", + ], + ] { + let output = cli().current_dir(temp.path()).args(&args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); + let problem = parse_stdout_json(&output); + assert!( + matches!( + problem["type"].as_str(), + Some("graph_required" | "managed_scope_conflict" | "token_clear_conflict") + ), + "{problem}" + ); + 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()); +} + +#[test] +fn managed_data_direct_override_uses_only_explicit_legacy_transport() { + 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 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 output = output_success( + cli() + .current_dir(temp.path()) + .env("OMNIGRAPH_BEARER_TOKEN", "explicit-legacy-token") + .env("OMNIGRAPH_CONTROL_TOKEN", "never-data") + .env("OMNIGRAPH_CONTROL_API", &api.origin) + .args(["query", "q", "--graph", "knowledge", "--server"]) + .arg(&data.origin) + .args(["--json", "--direct"]), + ); + assert_eq!( + parse_stdout_json(&output)["rows"], + serde_json::json!([{"value":42}]) + ); + let requests = data.requests(); + 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_use_verifies_access_before_writing_context() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-cli/tests/support/managed_http.rs b/crates/omnigraph-cli/tests/support/managed_http.rs index 3445feea7..f15ad1efe 100644 --- a/crates/omnigraph-cli/tests/support/managed_http.rs +++ b/crates/omnigraph-cli/tests/support/managed_http.rs @@ -112,11 +112,10 @@ impl IntentApiFixture { "HTTP/1.1 {} Fixture\r\nConnection: close\r\nContent-Type: application/json\r\n", reply.status ); - if !reply - .headers - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case("content-length")) - { + if !reply.headers.iter().any(|(name, _)| { + name.eq_ignore_ascii_case("content-length") + || name.eq_ignore_ascii_case("transfer-encoding") + }) { response.push_str(&format!("Content-Length: {}\r\n", reply.body.len())); } for (name, value) in reply.headers { diff --git a/crates/omnigraph-cluster/src/serve.rs b/crates/omnigraph-cluster/src/serve.rs index 42d5163f3..6a2c2a25b 100644 --- a/crates/omnigraph-cluster/src/serve.rs +++ b/crates/omnigraph-cluster/src/serve.rs @@ -37,6 +37,8 @@ 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, @@ -469,6 +471,7 @@ 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/store.rs b/crates/omnigraph-cluster/src/store.rs index 3ca79f736..e015be64d 100644 --- a/crates/omnigraph-cluster/src/store.rs +++ b/crates/omnigraph-cluster/src/store.rs @@ -108,6 +108,19 @@ impl ClusterStore { self.storage.kind() } + /// Canonical identity of the same store used for the serving snapshot. + /// This is metadata only; it does not reread the config or ledger. + pub(crate) fn canonical_root(&self) -> Result { + if self.kind() == StorageKind::Local { + let path = std::fs::canonicalize(&self.display_root).map_err(|err| { + Diagnostic::error("storage_root_invalid", "storage", err.to_string()) + })?; + Ok(format!("file://{}", path.to_string_lossy())) + } else { + Ok(self.root.clone()) + } + } + fn uri(&self, relative: &str) -> String { format!("{}/{}", self.root, relative) } diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index a392d654d..ce46bd4d6 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -4102,6 +4102,15 @@ async fn serving_snapshot_reads_converged_cluster() { let snapshot = read_serving_snapshot(dir.path()) .await .expect("converged cluster must serve"); + assert_eq!( + snapshot.canonical_root, + format!("file://{}", fs::canonicalize(dir.path()).unwrap().display()) + ); + let direct = read_serving_snapshot_from_storage(&snapshot.canonical_root) + .await + .unwrap(); + assert_eq!(direct.canonical_root, snapshot.canonical_root); + assert_eq!(direct.state_cas, snapshot.state_cas); 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/Cargo.toml b/crates/omnigraph-server/Cargo.toml index 95e777110..3b7522e56 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -39,6 +39,9 @@ futures = { workspace = true } url = { workspace = true } headers = "0.4.1" sha2 = { workspace = true } +base64 = { workspace = true } +p256 = { version = "0.13", features = ["ecdsa", "pem"] } +libc = "0.2" subtle = { workspace = true } async-trait = { workspace = true } arc-swap = { workspace = true } diff --git a/crates/omnigraph-server/src/data_tokens.rs b/crates/omnigraph-server/src/data_tokens.rs new file mode 100644 index 000000000..6907fdeb8 --- /dev/null +++ b/crates/omnigraph-server/src/data_tokens.rs @@ -0,0 +1,292 @@ +//! RFC 0053: bounded offline data credentials. Public trust is fixed at boot; +//! signature verification never performs network I/O or creates policy actors. + +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; +use color_eyre::eyre::{Result, bail, eyre}; +use omnigraph_policy::PolicyAction; +use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; +use p256::pkcs8::{DecodePublicKey, EncodePublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::graph_id::GraphId; +use crate::identity::{AuthSource, ResolvedActor, Scope}; + +pub const MAX_TOKEN_BYTES: usize = 8_192; +pub const MAX_TRUST_BYTES: usize = 65_536; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DataGrant { + pub graph_id: GraphId, + pub actions: Vec, +} + +/// Only authenticated, non-development principal classes enter this profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + Human, + Automation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DataAssurance { + VerifiedHuman, + VerifiedWorkload, +} + +/// Parsed claims are authenticated only after `DataTokenTrust::verify_at`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DataTokenClaims { + pub version: u8, + pub iss: String, + pub aud: String, + pub sub: String, + pub account_id: String, + pub cluster_id: String, + pub cluster_incarnation: String, + pub principal_kind: PrincipalKind, + pub assurance: DataAssurance, + pub iat: u64, + pub exp: u64, + pub jti: String, + pub grants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DataTokenHeader { + pub typ: String, + pub alg: String, + pub kid: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustDocument { + version: u8, + canonical_root: String, + account_id: String, + cluster_id: String, + cluster_incarnation: String, + issuer: String, + audience: String, + keys: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustKey { + kid: String, + public_key_pem: String, +} + +/// Validated immutable public trust. Private key material never enters it. +#[derive(Debug, Clone)] +pub struct DataTokenTrust { + account_id: String, + cluster_id: String, + cluster_incarnation: String, + issuer: String, + audience: String, + keys: Vec<(String, VerifyingKey)>, +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) +} + +fn valid_kid(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +fn valid_origin(value: &str) -> bool { + if value.is_empty() || value.len() > 2_048 { + return false; + } + let Ok(url) = Url::parse(value) else { + return false; + }; + url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url.host().is_some() + && url.origin().ascii_serialization() == value + && (url.scheme() == "https" + || (url.scheme() == "http" + && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")))) +} + +impl DataTokenTrust { + /// Read a bounded regular public file (projected-volume symlinks are allowed). + /// `expected_root` comes from the same Core serving snapshot being booted. + pub fn read(path: &Path, expected_root: &str) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path)?; + if !file.metadata()?.is_file() { + bail!("data-token trust must be a regular file"); + } + let mut bytes = Vec::new(); + file.take((MAX_TRUST_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + Self::from_json(&bytes, expected_root) + } + + /// Validate all trust entries and their binding before any graph engine opens. + pub fn from_json(bytes: &[u8], expected_root: &str) -> Result { + if bytes.len() > MAX_TRUST_BYTES { + bail!("data-token trust exceeds 64 KiB"); + } + let document: TrustDocument = serde_json::from_slice(bytes)?; + if document.version != 1 + || document.canonical_root.is_empty() + || document.canonical_root.len() > 4_096 + || document.canonical_root != expected_root + || !valid_id(&document.account_id) + || !valid_id(&document.cluster_id) + || !valid_id(&document.cluster_incarnation) + || !valid_origin(&document.issuer) + || document.audience != format!("urn:omnigraph:data:{}", document.cluster_id) + || !(1..=4).contains(&document.keys.len()) + { + bail!("invalid data-token trust or serving-root binding"); + } + let mut seen = HashSet::new(); + let mut keys = Vec::with_capacity(document.keys.len()); + for entry in document.keys { + let key = VerifyingKey::from_public_key_pem(&entry.public_key_pem) + .map_err(|_| eyre!("data-token trust key must be ECDSA P-256 SPKI PEM"))?; + let der = key.to_public_key_der()?; + let fingerprint = format!("{:x}", Sha256::digest(der.as_bytes())); + if !valid_kid(&entry.kid) || entry.kid != fingerprint || !seen.insert(entry.kid.clone()) + { + bail!("data-token trust has an invalid or duplicate key fingerprint"); + } + keys.push((entry.kid, key)); + } + Ok(Self { + account_id: document.account_id, + cluster_id: document.cluster_id, + cluster_incarnation: document.cluster_incarnation, + issuer: document.issuer, + audience: document.audience, + keys, + }) + } + + /// Verify using an explicit admission time. Failure is deliberately opaque: + /// callers must not log a credential or expose its unverified claims. + #[must_use] + pub fn verify_at(&self, token: &str, now: u64) -> Option { + if token.len() > MAX_TOKEN_BYTES { + return None; + } + let mut parts = token.split('.'); + let header_part = parts.next()?; + let claims_part = parts.next()?; + let signature_part = parts.next()?; + if parts.next().is_some() { + return None; + } + let header_bytes = URL_SAFE_NO_PAD.decode(header_part).ok()?; + let header: DataTokenHeader = serde_json::from_slice(&header_bytes).ok()?; + if header.typ != "JWT" || header.alg != "ES256" || !valid_kid(&header.kid) { + return None; + } + let (_, key) = self.keys.iter().find(|(kid, _)| *kid == header.kid)?; + let signature_bytes = URL_SAFE_NO_PAD.decode(signature_part).ok()?; + let signature = Signature::from_slice(&signature_bytes).ok()?; + key.verify( + format!("{header_part}.{claims_part}").as_bytes(), + &signature, + ) + .ok()?; + let claims_bytes = URL_SAFE_NO_PAD.decode(claims_part).ok()?; + let claims: DataTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; + 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, + }) + } + + fn valid_claims(&self, claims: &DataTokenClaims, now: u64) -> bool { + let Some(ttl) = claims.exp.checked_sub(claims.iat) else { + return false; + }; + if claims.version != 1 + || claims.iss != self.issuer + || claims.aud != self.audience + || claims.account_id != self.account_id + || claims.cluster_id != self.cluster_id + || claims.cluster_incarnation != self.cluster_incarnation + || !valid_id(&claims.sub) + || !valid_id(&claims.jti) + || !(60..=86_400).contains(&ttl) + || claims.exp <= now + || claims.iat > now.saturating_add(30) + || !(1..=64).contains(&claims.grants.len()) + || !matches!( + (claims.principal_kind, claims.assurance), + (PrincipalKind::Human, DataAssurance::VerifiedHuman) + | (PrincipalKind::Automation, DataAssurance::VerifiedWorkload) + ) + { + return false; + } + let mut graphs = HashSet::new(); + claims.grants.iter().all(|grant| { + let mut actions = HashSet::new(); + graphs.insert(grant.graph_id.as_str()) + && (1..=8).contains(&grant.actions.len()) + && grant.actions.iter().all(|action| { + matches!( + action, + PolicyAction::Read + | PolicyAction::Export + | PolicyAction::Change + | PolicyAction::BranchCreate + | PolicyAction::BranchDelete + | PolicyAction::BranchMerge + | PolicyAction::InvokeQuery + | PolicyAction::GraphList + ) && actions.insert(action) + }) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/omnigraph-server/src/data_tokens/tests.rs b/crates/omnigraph-server/src/data_tokens/tests.rs new file mode 100644 index 000000000..b0ba21023 --- /dev/null +++ b/crates/omnigraph-server/src/data_tokens/tests.rs @@ -0,0 +1,390 @@ +use super::*; +use p256::ecdsa::{SigningKey, signature::Signer}; +use serde_json::{Value, json}; + +fn golden() -> Value { + serde_json::from_str(include_str!("../../tests/fixtures/data-token-v1.json")).unwrap() +} + +fn document() -> Value { + let fixture = golden(); + json!({ + "version": 1, "canonical_root": "file:///fixture", + "account_id": fixture["claims"]["account_id"], + "cluster_id": fixture["claims"]["cluster_id"], + "cluster_incarnation": fixture["claims"]["cluster_incarnation"], + "issuer": fixture["claims"]["iss"], "audience": fixture["claims"]["aud"], + "keys": [{"kid": fixture["public_key_sha256"], "public_key_pem": fixture["public_key_pem"]}] + }) +} + +fn trust() -> DataTokenTrust { + DataTokenTrust::from_json(&serde_json::to_vec(&document()).unwrap(), "file:///fixture").unwrap() +} + +fn sign_raw(header: &str, claims: &str) -> String { + let key = SigningKey::from_slice(&[7; 32]).unwrap(); + let content = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header), + URL_SAFE_NO_PAD.encode(claims) + ); + let signature: Signature = key.sign(content.as_bytes()); + format!("{content}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) +} + +fn sign(claims: &Value) -> String { + sign_raw(&golden()["header"].to_string(), &claims.to_string()) +} + +#[test] +fn issuer_golden_signature_and_per_graph_ceiling() { + let fixture = golden(); + let trust = trust(); + let now = fixture["verification_time"].as_u64().unwrap(); + let mut actor = trust + .verify_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); + assert_eq!( + serde_json::to_value(actor.data_claims().unwrap()).unwrap(), + fixture["claims"] + ); + assert!(actor.select_graph(&GraphId::try_from("graph-a").unwrap())); + assert!(actor.permits_action(PolicyAction::Change)); + assert!(actor.select_graph(&GraphId::try_from("reports").unwrap())); + assert!(actor.permits_action(PolicyAction::Read)); + assert!(!actor.permits_action(PolicyAction::Change)); + assert!(!actor.select_graph(&GraphId::try_from("other").unwrap())); + // Tokens are reusable capabilities, not an invented one-use token ledger. + assert!( + trust + .verify_at(fixture["token"].as_str().unwrap(), now) + .is_some() + ); +} + +#[test] +fn signed_profile_rejects_invalid_authority_and_unsupported_claims() { + let fixture = golden(); + let now = fixture["verification_time"].as_u64().unwrap(); + let trust = trust(); + for (field, value) in [ + ("version", json!(2)), + ("iss", json!("https://other.example")), + ("aud", json!([fixture["claims"]["aud"]])), + ("account_id", json!("other")), + ("cluster_id", json!("other")), + ("cluster_incarnation", json!("other")), + ("sub", json!("email@example.com")), + ("sub", json!("a".repeat(129))), + ("jti", json!("")), + ("principal_kind", json!("development")), + ("assurance", json!("verified_workload")), + ("iat", json!(now + 31)), + ("iat", json!(-1)), + ("exp", json!(now)), + ("nbf", json!(now + 1)), + ("actor", json!("admin")), + ("grants", json!([])), + ( + "grants", + json!([{"graph_id":"graph-a","actions":["admin"]}]), + ), + ( + "grants", + json!([{"graph_id":"graph-a","actions":["schema_apply"]}]), + ), + ( + "grants", + json!([{"graph_id":"graph-a","actions":["read","read"]}]), + ), + ("grants", json!([{"graph_id":"graph-a","actions":["*"]}])), + ("grants", json!([{"graph_id":"graph_a","actions":["read"]}])), + ( + "grants", + json!([{"graph_id":"policies","actions":["read"]}]), + ), + ] { + let mut claims = fixture["claims"].clone(); + claims[field] = value; + assert!( + trust.verify_at(&sign(&claims), now).is_none(), + "accepted {field}: {claims}" + ); + } + let mut claims = fixture["claims"].clone(); + let duplicate = claims["grants"][0].clone(); + claims["grants"].as_array_mut().unwrap().push(duplicate); + assert!(trust.verify_at(&sign(&claims), now).is_none()); + claims["grants"] = Value::Array( + (0..65) + .map(|i| json!({"graph_id":format!("g{i}"),"actions":["read"]})) + .collect(), + ); + assert!(trust.verify_at(&sign(&claims), now).is_none()); + let duplicate = fixture["claims"] + .to_string() + .replacen('{', "{\"version\":1,", 1); + assert!( + trust + .verify_at(&sign_raw(&fixture["header"].to_string(), &duplicate), now) + .is_none() + ); +} + +#[test] +fn exact_time_limits_and_truthful_automation() { + let fixture = golden(); + let now = fixture["verification_time"].as_u64().unwrap(); + let trust = trust(); + for (ttl, accepted) in [(59, false), (60, true), (86_400, true), (86_401, false)] { + let mut claims = fixture["claims"].clone(); + claims["iat"] = json!(now); + claims["exp"] = json!(now + ttl); + assert_eq!(trust.verify_at(&sign(&claims), now).is_some(), accepted); + } + let mut claims = fixture["claims"].clone(); + claims["iat"] = json!(now + 30); + claims["exp"] = json!(now + 90); + claims["principal_kind"] = json!("automation"); + claims["assurance"] = json!("verified_workload"); + assert!(trust.verify_at(&sign(&claims), now).is_some()); + assert!(trust.verify_at(&sign(&claims), now + 90).is_none()); + claims["exp"] = json!(now + 86_430); + assert!( + trust.verify_at(&sign(&claims), now).is_some(), + "maximum signed lifetime permits 30 seconds of issuance-clock skew" + ); + assert!( + trust.verify_at(&sign(&claims), now + 86_430).is_none(), + "expiry has zero leeway even with a future issuance time" + ); + claims["exp"] = json!(now + 86_431); + assert!(trust.verify_at(&sign(&claims), now).is_none()); + claims["iat"] = json!(u64::MAX); + assert!(trust.verify_at(&sign(&claims), now).is_none()); +} + +#[test] +fn jose_header_signature_and_size_are_strict() { + let fixture = golden(); + let now = fixture["verification_time"].as_u64().unwrap(); + let trust = trust(); + for (field, value) in [ + ("alg", json!("none")), + ("alg", json!("HS256")), + ("typ", json!("other")), + ("kid", json!("0".repeat(64))), + ("jku", json!("https://attacker.example")), + ("crit", json!([])), + ] { + let mut header = fixture["header"].clone(); + header[field] = value; + assert!( + trust + .verify_at( + &sign_raw(&header.to_string(), &fixture["claims"].to_string()), + now + ) + .is_none() + ); + } + let token = fixture["token"].as_str().unwrap(); + let mut parts: Vec<_> = token.split('.').map(str::to_string).collect(); + parts[2] = URL_SAFE_NO_PAD.encode([0; 64]); + assert!(trust.verify_at(&parts.join("."), now).is_none()); + assert!(trust.verify_at(&format!("{token}.extra"), now).is_none()); + assert!( + trust + .verify_at(&"x".repeat(MAX_TOKEN_BYTES + 1), now) + .is_none() + ); + let duplicate = fixture["header"] + .to_string() + .replacen('{', "{\"alg\":\"ES256\",", 1); + assert!( + trust + .verify_at(&sign_raw(&duplicate, &fixture["claims"].to_string()), now) + .is_none() + ); +} + +#[test] +fn trust_bounds_fingerprints_origins_and_root_are_checked() { + let document = document(); + for (field, value) in [ + ("version", json!(2)), + ("canonical_root", json!("file:///other")), + ("canonical_root", json!("x".repeat(4097))), + ("issuer", json!("https://api.example.test/")), + ("issuer", json!("http://public.example")), + ("issuer", json!("https://a".to_string() + &"x".repeat(2048))), + ("issuer", json!("https://api.example.test/path")), + ("keys", json!([])), + ("account_id", json!("a".repeat(129))), + ("unknown", json!(true)), + ] { + let mut bad = document.clone(); + bad[field] = value; + assert!( + DataTokenTrust::from_json(&serde_json::to_vec(&bad).unwrap(), "file:///fixture") + .is_err(), + "accepted {field}" + ); + } + let mut bad = document.clone(); + bad["keys"][0]["kid"] = json!("0".repeat(64)); + assert!( + DataTokenTrust::from_json(&serde_json::to_vec(&bad).unwrap(), "file:///fixture").is_err() + ); + bad["keys"] = json!([document["keys"][0], document["keys"][0]]); + assert!( + DataTokenTrust::from_json(&serde_json::to_vec(&bad).unwrap(), "file:///fixture").is_err() + ); + assert!( + DataTokenTrust::from_json(&vec![b' '; MAX_TRUST_BYTES + 1], "file:///fixture").is_err() + ); + for origin in [ + "http://localhost:8080", + "http://127.0.0.1:80", + "http://[::1]:8080", + ] { + let mut local = document.clone(); + local["issuer"] = json!(origin); + // The explicit default port is not a canonical origin. + assert_eq!( + DataTokenTrust::from_json(&serde_json::to_vec(&local).unwrap(), "file:///fixture") + .is_ok(), + !origin.ends_with(":80") + ); + } +} + +#[test] +fn rotation_accepts_each_installed_key_without_online_discovery() { + let mut document = document(); + let key = SigningKey::from_slice(&[8; 32]).unwrap(); + let der = key.verifying_key().to_public_key_der().unwrap(); + let kid = format!("{:x}", Sha256::digest(der.as_bytes())); + let pem = key + .verifying_key() + .to_public_key_pem(Default::default()) + .unwrap(); + document["keys"] + .as_array_mut() + .unwrap() + .push(json!({"kid":kid,"public_key_pem":pem})); + let trust = + DataTokenTrust::from_json(&serde_json::to_vec(&document).unwrap(), "file:///fixture") + .unwrap(); + let fixture = golden(); + let now = fixture["verification_time"].as_u64().unwrap(); + assert!( + trust + .verify_at(fixture["token"].as_str().unwrap(), now) + .is_some() + ); + let mut header = fixture["header"].clone(); + header["kid"] = json!(kid); + let content = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(fixture["claims"].to_string()) + ); + let signature: Signature = key.sign(content.as_bytes()); + assert!( + trust + .verify_at( + &format!("{content}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())), + now + ) + .is_some() + ); +} + +#[test] +fn action_ceiling_has_no_implicit_permissions() { + let fixture = golden(); + let trust = trust(); + let now = fixture["verification_time"].as_u64().unwrap(); + let actions = [ + PolicyAction::Read, + PolicyAction::Change, + PolicyAction::Export, + PolicyAction::BranchCreate, + PolicyAction::BranchDelete, + PolicyAction::BranchMerge, + PolicyAction::InvokeQuery, + PolicyAction::GraphList, + ]; + 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(); + assert!(actor.select_graph(&GraphId::try_from("graph-a").unwrap())); + for checked in actions { + assert_eq!( + actor.permits_action(checked), + allowed == checked, + "{allowed} vs {checked}" + ); + } + assert!(!actor.permits_action(PolicyAction::SchemaApply)); + assert!(!actor.permits_action(PolicyAction::Admin)); + } +} + +#[test] +fn trust_file_reads_are_bounded_and_allow_projected_regular_files() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("trust.json"); + std::fs::write(&path, serde_json::to_vec(&document()).unwrap()).unwrap(); + assert!(DataTokenTrust::read(&path, "file:///fixture").is_ok()); + assert!(DataTokenTrust::read(temp.path(), "file:///fixture").is_err()); + #[cfg(unix)] + { + let link = temp.path().join("projected.json"); + std::os::unix::fs::symlink(&path, &link).unwrap(); + assert!(DataTokenTrust::read(&link, "file:///fixture").is_ok()); + let fifo = temp.path().join("fifo"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(status.success()); + assert!(DataTokenTrust::read(&fifo, "file:///fixture").is_err()); + } + std::fs::write(&path, vec![b' '; MAX_TRUST_BYTES + 1]).unwrap(); + assert!(DataTokenTrust::read(&path, "file:///fixture").is_err()); + let mut bad = document(); + bad["keys"][0]["public_key_pem"] = json!("not a P-256 public key"); + assert!( + DataTokenTrust::from_json(&serde_json::to_vec(&bad).unwrap(), "file:///fixture").is_err() + ); + // Valid SPKI for a different EC curve is still outside this fixed profile. + bad["keys"][0]["public_key_pem"] = json!(concat!( + "-----BEGIN PUBLIC KEY-----\n", + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAENZvc2bpMEtW1sf4GV905mNLjJzTsoLAN\n", + "QAt0OJTcapKwgYfcT7og3GSfKZf5H/WCIJCtfA1hNAHNck2uKqFeOCPcY/Ci6QV8\n", + "79TGgOXMmHNQC0W1kcZRixyx2ffP5h4/\n", + "-----END PUBLIC KEY-----\n" + )); + let der = base64::engine::general_purpose::STANDARD + .decode(concat!( + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAENZvc2bpMEtW1sf4GV905mNLjJzTsoLAN", + "QAt0OJTcapKwgYfcT7og3GSfKZf5H/WCIJCtfA1hNAHNck2uKqFeOCPcY/Ci6QV8", + "79TGgOXMmHNQC0W1kcZRixyx2ffP5h4/" + )) + .unwrap(); + bad["keys"][0]["kid"] = json!(format!("{:x}", Sha256::digest(der))); + assert!( + DataTokenTrust::from_json(&serde_json::to_vec(&bad).unwrap(), "file:///fixture").is_err() + ); + let duplicate = document().to_string().replacen('{', "{\"version\":1,", 1); + assert!(DataTokenTrust::from_json(duplicate.as_bytes(), "file:///fixture").is_err()); +} diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 36c3ec585..e1c81889d 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -114,9 +114,15 @@ pub(crate) async fn server_graphs_list( }, )?; + let may_list = |id: &str| { + actor + .as_ref() + .is_none_or(|actor| actor.0.permits_graph_listing(id)) + }; let mut graphs: Vec = registry .list() .into_iter() + .filter(|handle| may_list(handle.key.graph_id.as_str())) .map(|handle| GraphInfo { graph_id: handle.key.graph_id.as_str().to_string(), uri: handle.uri.clone(), @@ -125,7 +131,11 @@ pub(crate) async fn server_graphs_list( graphs.sort_by(|a, b| a.graph_id.cmp(&b.graph_id)); Ok(Json(GraphListResponse { graphs, - quarantined: state.quarantined_graphs(), + quarantined: state + .quarantined_graphs() + .into_iter() + .filter(|id| may_list(id)) + .collect(), })) } @@ -322,6 +332,11 @@ 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 !actor.select_graph(&graph_id) { + return Err(ApiError::forbidden("credential does not permit this graph")); + } + } let key = GraphKey::cluster(graph_id.clone()); let handle = match registry.get(&key) { RegistryLookup::Ready(handle) => handle, @@ -389,6 +404,18 @@ pub(crate) fn authorize( policy: Option<&PolicyEngine>, request: PolicyRequest, ) -> std::result::Result { + if let Some(actor) = actor { + if !actor.permits_action(request.action) { + return Ok(Authz::Denied( + "credential does not permit this action".to_string(), + )); + } + if actor.source == AuthSource::SignedData && policy.is_none() { + return Ok(Authz::Denied( + "signed data credentials require an applied Cedar policy permit".to_string(), + )); + } + } let Some(engine) = policy else { // No PolicyEngine installed. Three runtime states can reach this: // diff --git a/crates/omnigraph-server/src/identity.rs b/crates/omnigraph-server/src/identity.rs index 9aa41bc69..b5fd2c008 100644 --- a/crates/omnigraph-server/src/identity.rs +++ b/crates/omnigraph-server/src/identity.rs @@ -1,17 +1,7 @@ -//! Identity types for the multi-graph server (MR-668) plus forward-compatible -//! shapes for the Cloud/OAuth work tracked by MR-956. -//! -//! Per decision 13 in the implementation plan: ship the type shapes that -//! Cloud mode will consume, without committing to any trait shape -//! (`TokenVerifier` stays draft in MR-956). Every Cluster-mode call site -//! constructs these types with their Cluster-mode-specific values: -//! -//! - `tenant_id: None` (Cloud will set `Some(...)` from the OAuth `org_id` claim) -//! - `scopes: vec![Scope::Full]` (Cloud will populate from the OAuth `scope` claim) -//! - `source: AuthSource::Static` (Cloud / OIDC will set `AuthSource::Oidc`) -//! -//! The enums use `#[non_exhaustive]` so MR-956 can -//! add variants without breaking exhaustive matches in callers. +//! Server-resolved identities for static credentials and RFC 0053 offline +//! signed data credentials. Both use the cluster registry (`tenant_id: None`). +//! Signed identities retain exact graph/action grants; the selected graph +//! narrows those grants before the existing Cedar policy gate. use std::fmt; use std::sync::Arc; @@ -146,49 +136,46 @@ impl fmt::Display for GraphKey { } } -/// Authorization scope. Cluster mode: every authenticated actor gets -/// `Scope::Full`. MR-956 adds OAuth-style scopes via the -/// dashboard-configured `graph:read`, `graph:write`, `graph:admin`, -/// `graph:*` set; those become additional variants here. -/// -/// `#[non_exhaustive]` so MR-956 can extend without breaking matches. +/// Authorization shape. Static credentials use Cedar without an additional +/// grant ceiling; signed data credentials retain their exact grants. #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum Scope { - /// Full access. The Cluster-mode default — every authenticated actor - /// has unrestricted access subject to Cedar policy. + /// Static credential authority, subject to Cedar policy. Full, + /// Exact graph/action ceilings are retained in the authenticated claims. + DataToken, } -/// How the actor was authenticated. Cluster mode: every actor authenticates -/// via the existing SHA-256 hash compare against a static token set, so -/// `AuthSource::Static`. MR-956 adds `AuthSource::Oidc` when the -/// `OidcJwtVerifier` ships. -/// -/// `#[non_exhaustive]` so MR-956 can extend without breaking matches. +/// How the server authenticated the actor. #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum AuthSource { /// Authenticated via the static bearer-token hash table. Static, + /// RFC 0053 offline signed data credential; no managed-service call. + SignedData, } /// Server-resolved actor identity. Replaces the previous /// `AuthenticatedActor(Arc)` from `lib.rs`. /// /// The fields are populated by `authenticate_bearer_token` after a successful -/// constant-time hash match. **Clients cannot set any of these fields directly** +/// 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. /// -/// Cluster mode constructs this with `tenant_id: None`, `scopes: vec![Scope::Full]`, -/// `source: AuthSource::Static` via the convenience constructor below. +/// The static constructor uses `Scope::Full`; the data-token verifier attaches +/// validated claims and uses `Scope::DataToken`. #[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 { @@ -200,6 +187,8 @@ impl ResolvedActor { tenant_id: None, scopes: vec![Scope::Full], source: AuthSource::Static, + data_token: None, + selected_graph: None, } } @@ -208,6 +197,53 @@ impl ResolvedActor { pub fn actor_id_str(&self) -> &str { &self.actor_id } + + /// Authenticated signed claims, excluding the original bearer plaintext. + pub fn data_claims(&self) -> Option<&crate::data_tokens::DataTokenClaims> { + self.data_token.as_deref() + } + + pub(crate) fn select_graph(&mut self, graph_id: &GraphId) -> bool { + if self.source == AuthSource::Static { + return true; + } + if self.data_token.as_ref().is_some_and(|claims| { + claims + .grants + .iter() + .any(|grant| grant.graph_id == *graph_id) + }) { + self.selected_graph = Some(graph_id.clone()); + return true; + } + false + } + + pub(crate) fn permits_action(&self, action: omnigraph_policy::PolicyAction) -> bool { + if self.source == AuthSource::Static { + return true; + } + self.data_token.as_ref().is_some_and(|claims| { + claims.grants.iter().any(|grant| { + (self.selected_graph.as_ref() == Some(&grant.graph_id) + || (self.selected_graph.is_none() + && action == omnigraph_policy::PolicyAction::GraphList)) + && grant.actions.contains(&action) + }) + }) + } + + pub(crate) fn permits_graph_listing(&self, graph_id: &str) -> bool { + self.source == AuthSource::Static + || self.data_token.as_ref().is_some_and(|claims| { + claims.grants.iter().any(|grant| { + grant.graph_id.as_str() == graph_id + && grant + .actions + .contains(&omnigraph_policy::PolicyAction::GraphList) + }) + }) + } } #[cfg(test)] diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index db9fa5203..e9ba56c8b 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -7,6 +7,7 @@ use handlers::*; use settings::*; pub use settings::{ServerRuntimeState, classify_server_runtime_state, load_server_settings}; pub mod auth; +pub mod data_tokens; pub mod graph_id; pub mod identity; pub mod policy; @@ -176,8 +177,12 @@ 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 neither bearer tokens nor a policy file are configured, + /// When no static tokens, signed-token trust, or policy are configured, /// `serve()` refuses to start unless this is true (set via /// `--unauthenticated` or `OMNIGRAPH_UNAUTHENTICATED=1`). The /// motivation is that "no tokens + no policy" looks like protection @@ -336,6 +341,7 @@ pub struct AppState { /// see MR-668 decision Q6. workload: Arc, bearer_tokens: Arc<[(BearerTokenHash, Arc)]>, + data_token_trust: Option>, /// Server-level Cedar policy. Used by management endpoints (`GET /// /graphs`) which act on the registry resource, not on a per-graph /// resource. Loaded from the cluster-scoped policy binding when @@ -632,6 +638,7 @@ impl AppState { workload, bearer_tokens, server_policy: None, + data_token_trust: None, export_transport: export_transport::ExportTransport::with_defaults(), witness: Arc::new(BootWitness::default()), draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -662,6 +669,7 @@ impl AppState { workload: Arc::new(workload), bearer_tokens, server_policy: server_policy.map(Arc::new), + data_token_trust: None, export_transport: export_transport::ExportTransport::with_defaults(), witness: Arc::new(BootWitness::default()), draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -713,8 +721,16 @@ impl AppState { &self.routing } + /// Attach already validated boot trust. Production validates the root + /// before opening any graph; embedded HTTP hosts own their boot binding. + #[must_use] + pub fn with_data_token_trust(mut self, trust: data_tokens::DataTokenTrust) -> Self { + self.data_token_trust = Some(Arc::new(trust)); + self + } + fn requires_bearer_auth(&self) -> bool { - if !self.bearer_tokens.is_empty() { + if !self.bearer_tokens.is_empty() || self.data_token_trust.is_some() { return true; } if self.server_policy.is_some() { @@ -738,7 +754,15 @@ impl AppState { matched = Some(Arc::clone(actor)); } } - matched.map(ResolvedActor::cluster_static) + matched.map(ResolvedActor::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) + }) } } @@ -1958,6 +1982,11 @@ 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?; @@ -1974,7 +2003,7 @@ pub async fn serve(config: ServerConfig) -> Result<()> { } => server_policy.is_some() || graphs.iter().any(|g| g.policy.is_some()), }; let runtime_state = classify_server_runtime_state( - !tokens.is_empty(), + !tokens.is_empty() || data_token_trust.is_some(), has_policy_configured, config.allow_unauthenticated, )?; @@ -1986,8 +2015,8 @@ pub async fn serve(config: ServerConfig) -> Result<()> { ), ServerRuntimeState::DefaultDeny => warn!( "bearer tokens are configured but no policy file is set — running in \ - default-deny mode (only `read` actions are permitted for authenticated \ - actors). Configure a graph or cluster policy bundle in the cluster config, \ + default-deny mode (static credentials permit `read`; signed data \ + credentials require an explicit policy permit). Configure a graph or cluster policy bundle in the cluster config, \ run `omnigraph cluster apply`, and restart to enable Cedar rules." ), ServerRuntimeState::PolicyEnabled => {} @@ -2018,6 +2047,10 @@ pub async fn serve(config: ServerConfig) -> Result<()> { } }; + let state = match data_token_trust { + Some(trust) => state.with_data_token_trust(trust), + None => state, + }; let listener = TcpListener::bind(&bind).await?; let listen_addr = listener.local_addr()?; { diff --git a/crates/omnigraph-server/src/main.rs b/crates/omnigraph-server/src/main.rs index 01b6431eb..f8de8cae0 100644 --- a/crates/omnigraph-server/src/main.rs +++ b/crates/omnigraph-server/src/main.rs @@ -23,9 +23,13 @@ struct Cli { cluster: Option, #[arg(long)] bind: Option, - /// Run without bearer tokens and without a policy file (MR-723). - /// Required when neither is configured — otherwise the server - /// refuses to start to prevent shipping the illusion of protection. + /// Public JSON trust for offline signed data credentials (RFC 0053). + /// Its canonical root must match this serving snapshot. Read once at boot. + #[arg(long)] + data_token_trust: Option, + /// Run without credential sources and without a policy file (MR-723). + /// Required when no static tokens, signed-token trust, or policy is + /// configured — otherwise startup refuses to prevent an unprotected deployment. /// Equivalent to setting `OMNIGRAPH_UNAUTHENTICATED=1`. #[arg(long)] unauthenticated: bool, @@ -57,5 +61,6 @@ async fn main() -> Result<()> { ) .await?; settings.shutdown_grace = resolve_shutdown_grace(cli.shutdown_grace_seconds)?; + settings.data_token_trust = cli.data_token_trust; serve(settings).await } diff --git a/crates/omnigraph-server/src/settings.rs b/crates/omnigraph-server/src/settings.rs index be1388350..bef43ce88 100644 --- a/crates/omnigraph-server/src/settings.rs +++ b/crates/omnigraph-server/src/settings.rs @@ -207,6 +207,8 @@ pub(crate) async fn load_cluster_settings( 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, @@ -677,6 +679,8 @@ 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(), @@ -733,6 +737,8 @@ 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 22a8514e4..de73d34ee 100644 --- a/crates/omnigraph-server/tests/auth_policy.rs +++ b/crates/omnigraph-server/tests/auth_policy.rs @@ -22,6 +22,246 @@ use tower::ServiceExt; mod support; use support::*; +#[tokio::test(flavor = "multi_thread")] +async fn signed_data_tokens_narrow_policy_and_attribute_writes() { + let tokens = data_tokens::DataTokens::new(); + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let policy_path = temp.path().join("policy.yaml"); + fs::write( + &policy_path, + permit_all_policy_yaml(&[&tokens.actor, "breakglass"]), + ) + .unwrap(); + let state = AppState::open_with_bearer_tokens_and_policy( + graph.to_string_lossy().to_string(), + vec![("breakglass".into(), "explicit.static.credential".into())], + Some(&policy_path), + ) + .await + .unwrap() + .with_data_token_trust(tokens.trust.clone()); + let app = build_app(state); + let read = tokens.token(json!([ + {"graph_id":"default","actions":["read"]}, + {"graph_id":"reports","actions":["change"]} + ])); + let request = || { + Request::builder().uri(g("/mutate")).method(Method::POST) + .header("authorization",format!("Bearer {read}")) + .header("x-actor-id","breakglass") + .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() + }; + let (status, before) = + json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; + assert_eq!(status, StatusCode::OK); + let (status, _) = json_response(&app, request()).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "another graph's change grant must not leak" + ); + let (_, after) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; + assert_eq!(after, before, "denial must not publish a commit"); + let (status, _) = json_response( + &app, + get_request("/graphs/hidden/snapshot?branch=main", &read), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "no graph existence probe outside the token grant" + ); + let (status, _) = json_response( + &app, + get_request(&g("/snapshot?branch=main"), "explicit.static.credential"), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "explicit static dot credentials still work" + ); + let write = tokens.token(json!([{"graph_id":"default","actions":["read","change"]}])); + let mut allowed = request(); + allowed + .headers_mut() + .insert("authorization", format!("Bearer {write}").parse().unwrap()); + let (status, body) = json_response(&app, allowed).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["actor_id"], tokens.actor); + let (_, commits) = json_response(&app, get_request(&g("/commits?branch=main"), &write)).await; + assert_eq!(commits["commits"][0]["actor_id"], tokens.actor); + assert_ne!(commits, before); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_data_requires_cedar_and_rejects_forgery_on_every_protected_route() { + let tokens = data_tokens::DataTokens::new(); + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let state = AppState::open_with_bearer_tokens( + graph.to_string_lossy().to_string(), + vec![("breakglass".into(), "static-token".into())], + ) + .await + .unwrap() + .with_data_token_trust(tokens.trust.clone()); + let app = build_app(state); + let token = tokens.token(json!([{"graph_id":"default","actions":["read"]}])); + let (status, _) = json_response(&app, get_request(&g("/snapshot?branch=main"), &token)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "JWT must not inherit static no-policy read default" + ); + let (status, _) = json_response( + &app, + get_request(&g("/snapshot?branch=main"), "static-token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let policy_path = temp.path().join("other-principal.yaml"); + fs::write(&policy_path, permit_all_policy_yaml(&["someone-else"])).unwrap(); + let unknown_actor_app = build_app( + AppState::open_with_bearer_tokens_and_policy( + graph.to_string_lossy().to_string(), + Vec::new(), + Some(&policy_path), + ) + .await + .unwrap() + .with_data_token_trust(tokens.trust.clone()), + ); + let (status, _) = json_response( + &unknown_actor_app, + get_request(&g("/snapshot?branch=main"), &token), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a valid token must not enroll its actor in Cedar" + ); + let state = AppState::open(graph.to_string_lossy().to_string()) + .await + .unwrap() + .with_data_token_trust(tokens.trust); + let app = build_app(state); + for (method, path) in [ + (Method::GET, "/snapshot"), + (Method::GET, "/blob"), + (Method::HEAD, "/blob"), + (Method::POST, "/export"), + (Method::POST, "/read"), + (Method::POST, "/query"), + (Method::POST, "/change"), + (Method::POST, "/mutate"), + (Method::POST, "/mutate/if-graph-commit"), + (Method::GET, "/queries"), + (Method::POST, "/queries/find_person"), + (Method::POST, "/queries/find_person/if-graph-commit"), + (Method::GET, "/schema"), + (Method::POST, "/schema/apply"), + (Method::POST, "/load"), + (Method::POST, "/load/ndjson"), + (Method::POST, "/ingest"), + (Method::GET, "/branches"), + (Method::POST, "/branches"), + (Method::DELETE, "/branches/feature"), + (Method::POST, "/branches/merge"), + (Method::GET, "/commits"), + (Method::GET, "/commits/unknown"), + (Method::GET, "/commits/unknown/changes"), + (Method::GET, "/changes"), + (Method::POST, "/changes/baseline"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(g(path)) + .header("authorization", "Bearer invalid.jwt.signature") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + } + for path in ["/graphs", "/graphs/default/snapshot"] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "JWT-only boot must require auth" + ); + } + let (status, _) = json_response(&app, get_request("/healthz", "invalid")).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_merge_cannot_smuggle_source_deletion_through_merge_grant() { + let tokens = data_tokens::DataTokens::new(); + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); + db.branch_create_from(ReadTarget::branch("main"), "feature") + .await + .unwrap(); + drop(db); + let policy_path = temp.path().join("policy.yaml"); + fs::write(&policy_path, permit_all_policy_yaml(&[&tokens.actor])).unwrap(); + let app = build_app( + AppState::open_with_bearer_tokens_and_policy( + graph.to_string_lossy().to_string(), + Vec::new(), + Some(&policy_path), + ) + .await + .unwrap() + .with_data_token_trust(tokens.trust.clone()), + ); + let token = tokens.token(json!([{"graph_id":"default","actions":["read","branch_merge"]}])); + let (status, body) = json_response( + &app, + Request::builder() + .method(Method::POST) + .uri(g("/branches/merge")) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({"source":"feature","target":"main","delete_branch":true}).to_string(), + )) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["outcome"], "already_up_to_date"); + assert_eq!(body["branch_deleted"], false); + assert!( + body["branch_delete_error"] + .as_str() + .unwrap() + .contains("credential does not permit") + ); + let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap(); + assert!( + db.branch_list() + .await + .unwrap() + .contains(&"feature".to_string()) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn healthz_succeeds_after_startup() { let (_temp, app) = app_for_loaded_graph().await; diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index 51f5cf278..30f80e3dc 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -14,13 +14,70 @@ use tower::ServiceExt; mod support; use support::*; +#[tokio::test] +async fn data_trust_root_mismatch_refuses_before_recovery_open() { + use omnigraph_server::queries::QueryRegistry; + use omnigraph_server::{ + BootWitness, DEFAULT_SHUTDOWN_GRACE, GraphStartupConfig, 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 { + 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(), + 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, + witness: BootWitness::default(), + shutdown_grace: DEFAULT_SHUTDOWN_GRACE, + }) + .await; + assert!( + result + .unwrap_err() + .to_string() + .contains("serving-root binding") + ); + assert!( + staging.exists(), + "invalid trust must not open the graph for recovery" + ); +} + mod multi_graph_startup { use super::*; use omnigraph::storage::normalize_root_uri; use omnigraph_server::{GraphHandle, GraphId, GraphKey, GraphRegistry, InsertError}; use std::sync::Arc; - async fn build_multi_mode_app(graph_ids: &[&str]) -> (Vec, Router) { + async fn build_multi_mode_state( + graph_ids: &[&str], + policy: Option, + ) -> (Vec, AppState) { let mut dirs = Vec::with_capacity(graph_ids.len()); let mut handles = Vec::with_capacity(graph_ids.len()); for id in graph_ids { @@ -38,9 +95,51 @@ mod multi_graph_startup { dirs.push(dir); } let workload = omnigraph_server::workload::WorkloadController::from_env(); - let state = AppState::new_multi(handles, Vec::new(), None, workload, None).unwrap(); + let state = AppState::new_multi(handles, Vec::new(), policy, workload, None).unwrap(); + (dirs, state) + } + + async fn build_multi_mode_app(graph_ids: &[&str]) -> (Vec, Router) { + let (dirs, state) = build_multi_mode_state(graph_ids, None).await; + (dirs, build_app(state)) + } + + #[tokio::test(flavor = "multi_thread")] + async fn signed_registry_lists_only_granted_served_and_quarantined_graphs() { + let tokens = data_tokens::DataTokens::new(); + let policy = omnigraph_policy::PolicyEngine::load_server_from_source(&format!( + "version: 1\ngroups:\n viewers: [\"{}\"]\nrules:\n - id: list\n allow:\n actors: {{group: viewers}}\n actions: [graph_list]\n",tokens.actor + )).unwrap(); + let (_dirs, state) = build_multi_mode_state(&["alpha", "beta"], Some(policy)).await; + let state = state + .with_data_token_trust(tokens.trust.clone()) + .with_boot_witness( + omnigraph_server::BootWitness { + applied_graphs: vec![ + "alpha".into(), + "beta".into(), + "ghost-allowed".into(), + "ghost-hidden".into(), + ], + ..Default::default() + }, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + omnigraph_server::DEFAULT_SHUTDOWN_GRACE, + ); let app = build_app(state); - (dirs, app) + let token = tokens.token(serde_json::json!([ + {"graph_id":"alpha","actions":["graph_list"]}, + {"graph_id":"beta","actions":["read"]}, + {"graph_id":"ghost-allowed","actions":["graph_list"]} + ])); + let (status, body) = json_response(&app, get_request("/graphs", &token)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["graphs"].as_array().unwrap().len(), 1); + assert_eq!(body["graphs"][0]["graph_id"], "alpha"); + assert_eq!(body["quarantined"], serde_json::json!(["ghost-allowed"])); + let read = tokens.token(serde_json::json!([{"graph_id":"alpha","actions":["read"]}])); + let (status, _) = json_response(&app, get_request("/graphs", &read)).await; + assert_eq!(status, StatusCode::FORBIDDEN); } /// Cluster route `/graphs/{graph_id}/snapshot` resolves to the right diff --git a/crates/omnigraph-server/tests/fixtures/data-token-v1.json b/crates/omnigraph-server/tests/fixtures/data-token-v1.json new file mode 100644 index 000000000..ee3671f03 --- /dev/null +++ b/crates/omnigraph-server/tests/fixtures/data-token-v1.json @@ -0,0 +1,42 @@ +{ + "description": "Test-only P-256 fixture generated from scalar 0x07 repeated32; no production key or credential.", + "verification_time": 1788610060, + "header": { + "typ": "JWT", + "alg": "ES256", + "kid": "ce97ec20d43661a19b215cb2e376a698d0b4aa2aa5b4c7854e916bd05082fcee" + }, + "claims": { + "version": 1, + "iss": "https://api.example.test", + "aud": "urn:omnigraph:data:01M00000000000000000000001", + "sub": "01M00000000000000000000002", + "account_id": "org_fixture", + "cluster_id": "01M00000000000000000000001", + "cluster_incarnation": "01M00000000000000000000003", + "principal_kind": "human", + "assurance": "verified_human", + "iat": 1788610000, + "exp": 1788613600, + "jti": "01M00000000000000000000004", + "grants": [ + { + "graph_id": "graph-a", + "actions": [ + "change", + "invoke_query", + "read" + ] + }, + { + "graph_id": "reports", + "actions": [ + "read" + ] + } + ] + }, + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImNlOTdlYzIwZDQzNjYxYTE5YjIxNWNiMmUzNzZhNjk4ZDBiNGFhMmFhNWI0Yzc4NTRlOTE2YmQwNTA4MmZjZWUifQ.eyJ2ZXJzaW9uIjoxLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLnRlc3QiLCJhdWQiOiJ1cm46b21uaWdyYXBoOmRhdGE6MDFNMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJzdWIiOiIwMU0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMiIsImFjY291bnRfaWQiOiJvcmdfZml4dHVyZSIsImNsdXN0ZXJfaWQiOiIwMU0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMSIsImNsdXN0ZXJfaW5jYXJuYXRpb24iOiIwMU0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMyIsInByaW5jaXBhbF9raW5kIjoiaHVtYW4iLCJhc3N1cmFuY2UiOiJ2ZXJpZmllZF9odW1hbiIsImlhdCI6MTc4ODYxMDAwMCwiZXhwIjoxNzg4NjEzNjAwLCJqdGkiOiIwMU0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwNCIsImdyYW50cyI6W3siZ3JhcGhfaWQiOiJncmFwaC1hIiwiYWN0aW9ucyI6WyJjaGFuZ2UiLCJpbnZva2VfcXVlcnkiLCJyZWFkIl19LHsiZ3JhcGhfaWQiOiJyZXBvcnRzIiwiYWN0aW9ucyI6WyJyZWFkIl19XX0.dk9Qip2xaddAxio2TKQ7-oxpyc2cWAv8Ob7gMlst7Z8YymtupTMQ83P911VPHYJBnxKIY1Ct5RFIyTZuBPwAnA", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHhhTL9R1TALzBB2cdc6zO4P/2BrH\nzk/ogsyxyYvFiW6kbDEcTi/0DdlqNlPm5FRF0y3+SG7O11x6kMahiIHAow==\n-----END PUBLIC KEY-----\n", + "public_key_sha256": "ce97ec20d43661a19b215cb2e376a698d0b4aa2aa5b4c7854e916bd05082fcee" +} diff --git a/crates/omnigraph-server/tests/stored_queries.rs b/crates/omnigraph-server/tests/stored_queries.rs index 57d66c84e..457f24a58 100644 --- a/crates/omnigraph-server/tests/stored_queries.rs +++ b/crates/omnigraph-server/tests/stored_queries.rs @@ -9,6 +9,70 @@ use serde_json::{Value, json}; mod support; use support::*; +#[tokio::test] +async fn signed_stored_invocation_requires_both_outer_and_inner_grants() { + let tokens = data_tokens::DataTokens::new(); + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let policy_path = temp.path().join("policy.yaml"); + let policy = format!( + "{}\n - id: invoke\n allow:\n actors: {{group: permitted}}\n actions: [invoke_query]\n", + permit_all_policy_yaml(&[&tokens.actor]) + ); + std::fs::write(&policy_path, policy).unwrap(); + let registry = stored_query_registry(&[ + ( + "signed_read", + "query signed_read() { match { $p: Person } return { $p.name } }", + true, + ), + ( + "signed_insert", + "query signed_insert($name: String, $age: I32) { insert Person { name: $name, age: $age } }", + true, + ), + ]); + let state = AppState::open_single_with_queries( + graph.to_string_lossy().to_string(), + vec![], + Some(&policy_path), + registry, + ) + .await + .unwrap() + .with_data_token_trust(tokens.trust.clone()); + let app = omnigraph_server::build_app(state); + let read = tokens.token(json!([{"graph_id":"default","actions":["read"]}])); + let (status, _) = json_response(&app, invoke_request("signed_read", &read, json!({}))).await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "missing invoke grant must hide the query" + ); + let invoke = tokens.token(json!([{"graph_id":"default","actions":["invoke_query","read"]}])); + let (status, _) = json_response(&app, invoke_request("signed_read", &invoke, json!({}))).await; + assert_eq!(status, StatusCode::OK); + let (_, before) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; + let params = json!({"params":{"name":"Scoped","age":31}}); + let (status, _) = json_response( + &app, + invoke_request("signed_insert", &invoke, params.clone()), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "invoke must not authorize an inner mutation" + ); + let (_, after) = json_response(&app, get_request(&g("/commits?branch=main"), &read)).await; + assert_eq!(after, before); + let write = + tokens.token(json!([{"graph_id":"default","actions":["invoke_query","change","read"]}])); + let (status, body) = json_response(&app, invoke_request("signed_insert", &write, params)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["actor_id"], tokens.actor); +} + async fn assert_receipt_commit_matches_get(app: &axum::Router, output: &Value, token: &str) { let receipt = output .get("commit") diff --git a/crates/omnigraph-server/tests/support/data_tokens.rs b/crates/omnigraph-server/tests/support/data_tokens.rs new file mode 100644 index 000000000..bebce9a7c --- /dev/null +++ b/crates/omnigraph-server/tests/support/data_tokens.rs @@ -0,0 +1,57 @@ +//! Shared signed-credential transport fixture. Its fixed key is test-only. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use omnigraph_server::data_tokens::DataTokenTrust; +use p256::ecdsa::{Signature, SigningKey, signature::Signer}; +use serde_json::{Value, json}; + +pub struct DataTokens { + pub trust: DataTokenTrust, + pub actor: String, + pub document: Value, + fixture: Value, +} + +impl DataTokens { + pub fn new() -> Self { + let fixture: Value = + serde_json::from_str(include_str!("../fixtures/data-token-v1.json")).unwrap(); + let document = json!({ + "version":1,"canonical_root":"file:///fixture", + "account_id":fixture["claims"]["account_id"],"cluster_id":fixture["claims"]["cluster_id"], + "cluster_incarnation":fixture["claims"]["cluster_incarnation"], + "issuer":fixture["claims"]["iss"],"audience":fixture["claims"]["aud"], + "keys":[{"kid":fixture["public_key_sha256"],"public_key_pem":fixture["public_key_pem"]}] + }); + Self { + trust: DataTokenTrust::from_json( + &serde_json::to_vec(&document).unwrap(), + "file:///fixture", + ) + .unwrap(), + actor: format!("principal:{}", fixture["claims"]["sub"].as_str().unwrap()), + document, + fixture, + } + } + + pub fn token(&self, grants: Value) -> String { + let mut claims = self.fixture["claims"].clone(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + claims["iat"] = json!(now); + claims["exp"] = json!(now + 3600); + claims["grants"] = grants; + let content = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(self.fixture["header"].to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + let key = SigningKey::from_slice(&[7; 32]).unwrap(); + let signature: Signature = key.sign(content.as_bytes()); + format!("{content}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) + } +} diff --git a/crates/omnigraph-server/tests/support/mod.rs b/crates/omnigraph-server/tests/support/mod.rs index d21f0b9c2..4cef7c922 100644 --- a/crates/omnigraph-server/tests/support/mod.rs +++ b/crates/omnigraph-server/tests/support/mod.rs @@ -2,6 +2,8 @@ //! from the monolithic tests/server.rs in the modularization). #![allow(dead_code)] +pub mod data_tokens; + use std::env; use std::fs; use std::path::{Path, PathBuf}; diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index b548be471..3c44205b9 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -71,6 +71,17 @@ 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 +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 +the serving request path. + A replica reports what it booted from on `GET /readyz` (RFC 0049): the applied `config_digest` as `booted_serving_digest`, the ledger revision and CAS, and how many applied graphs it serves and does not; it answers 503 from diff --git a/docs/releases/v0.11.0.md b/docs/releases/v0.11.0.md index f6d4c779d..dac5f46b0 100644 --- a/docs/releases/v0.11.0.md +++ b/docs/releases/v0.11.0.md @@ -4,6 +4,15 @@ Notes accumulate here until the release is cut. ## Highlights +- **Managed data access uses a separate credential.** `cluster token` mints + a bounded graph/action credential into the OS keychain. In a managed + folder, `query` and `mutate` use that credential directly against the data + endpoint, including while the control API is offline. The server applies + current Cedar policy to the signed grants. Expired or missing credentials + refuse without fallback; `--clear` forgets the local credential, and global + `--direct` explicitly selects legacy routing. Tokens never appear in CLI + output. See [managed data access](../user/cli/managed-data.md). + - **Managed cluster operations from the existing CLI.** `login --api` stores a short-lived session in the OS keychain, and `use CLUSTER_ID --api` selects the Intent API for a config directory. `cluster plan`, exact saved-plan @@ -206,6 +215,13 @@ Notes accumulate here until the release is cut. ## Server +- **Offline signed data credentials.** `--data-token-trust` binds public + P-256 keys to the server's actual cluster root and identity before graph + open. Signed tokens resolve an immutable principal actor and narrow applied + Cedar policy to exact graph/action grants; they cannot grant schema changes + or administration. Verification needs no issuer connection. Existing static + credentials can coexist for recovery. See [signed data + credentials](../user/operations/server.md#signed-data-credentials). - **Readiness witness.** `GET /readyz` reports whether a replica is serving or draining, the applied `config_digest` it booted from (`booted_serving_digest`), the ledger revision and CAS it read, and how diff --git a/docs/rfcs/0053-offline-data-token-verification.md b/docs/rfcs/0053-offline-data-token-verification.md new file mode 100644 index 000000000..71c644b4e --- /dev/null +++ b/docs/rfcs/0053-offline-data-token-verification.md @@ -0,0 +1,356 @@ +--- +rfc: "0053" +title: "Offline data-token verification" +track: maintainer +status: accepted +implementation: complete +authors: + - andrew +created: 2026-09-05 +updated: 2026-09-05 +discussion: https://github.com/ModernRelay/omnigraph/pull/633 +supersedes: [] +superseded_by: [] +blocked_on: [] +--- + +# RFC 0053: Offline data-token verification + +## Summary + +Accept bounded, short-lived ES256 data credentials at the existing HTTP +boundary. An operator supplies public trust at boot; the server verifies every +credential offline, derives its actor from the authenticated principal, and +intersects its graph/action grants with the existing Cedar policy. An issuer +cannot use this credential to bypass graph policy or authorize schema changes. + +The contract is generic OSS behavior. No managed-service library, network +discovery, token database, engine fork, or graph-storage format is introduced. + +## Motivation + +The server currently authenticates configured static bearer tokens. Managed +login establishes a different credential for the control service, which must +never be forwarded to graph endpoints. Short-lived data credentials let an +authorized caller query and mutate an existing cluster while serving remains +independent of the issuer's availability. + +Identity, attenuation, expiry, trust custody, and root binding are public +security contracts. They require a decision before implementation rather than +an incidental alternative authentication branch. + +## User and operational behavior + +An operator starts the cluster server with public trust: + +```sh +omnigraph-server --cluster s3://customer/root --data-token-trust trust.json +``` + +The flag is optional. Without it, existing static-token, policy, and explicit +unauthenticated behavior is unchanged. With it, protected routes require a +credential even when no static tokens are configured. Public health, readiness, +and OpenAPI endpoints retain their current behavior. + +Malformed, expired, wrongly scoped, or untrusted credentials never become +anonymous requests. Authentication failures are 401; action/graph denials are +403, except existing resource-hiding conventions such as stored-query denials +remaining indistinguishable from missing queries. Denial occurs before graph +lookup or effect wherever that contract applies. + +Token validity is checked at request admission. Expiry does not cancel an +already admitted mutation or stream. Revocation of a grant or login session +prevents future issuance; an issued token remains usable until its expiry or +the serving process restarts with trust that excludes its signing key. There +is no immediate online revocation promise. + +## Design + +### Signed credential + +The credential is a compact JWT of at most 8,192 bytes. Its protected header is +exactly `alg: ES256`, `typ: JWT`, and `kid`, with no duplicate or unknown fields. +The signature uses JOSE's fixed-width P-256 encoding; issuers using a signer +that returns ASN.1 DER must convert it before serialization. `kid` is the +64-character lowercase hexadecimal SHA-256 of the signing public key's SPKI +DER representation. + +The version 1 claims are a JSON object with these fields, without duplicates +or unknown fields: + +```json +{ + "version": 1, + "iss": "https://control.example", + "aud": "urn:omnigraph:data:cluster-1", + "sub": "principal-id", + "account_id": "account-1", + "cluster_id": "cluster-1", + "cluster_incarnation": "incarnation-1", + "principal_kind": "human", + "assurance": "verified_human", + "iat": 1788612000, + "exp": 1788612900, + "jti": "token-id", + "grants": [{"graph_id": "knowledge", "actions": ["read", "change"]}] +} +``` + +`iss` is the canonical control API origin, at most 2,048 bytes, without a trailing slash, userinfo, +path, query, or fragment. HTTPS is required except exact loopback HTTP origins +for local qualification. `aud` is the exact string +`urn:omnigraph:data:`, not an array. The account, cluster id, and +cluster incarnation must all equal the operator's boot trust. + +`sub` is the issuer's immutable local principal id, never an email or a caller +selected actor. The server derives the Cedar actor as `principal:`. +The only valid kind/assurance pairs are `human`/`verified_human` and +`automation`/`verified_workload`. Development, self-asserted, and impersonated +credentials cannot use this profile. Principal/account/cluster/incarnation +strings and `jti` contain 1–128 ASCII letters, digits, hyphens, or underscores. +The latter is provenance, +not a persisted replay ledger: data credentials are reusable until expiry. + +`iat` and `exp` are integer Unix seconds. Lifetime `exp - iat` is 60–86,400 +seconds inclusive. Admission requires `now < exp`, with zero expiry leeway, +and `iat <= now + 30`. A maximum-lifetime credential issued by a clock 30 +seconds ahead can therefore have 86,430 seconds remaining at admission. +Time arithmetic is checked. Other temporal claims, +including `nbf`, are unsupported in this fixed version and refuse rather than +being silently ignored. The issuer may issue a shorter token than the caller +requests according to its current permissions and configured limit. + +`grants` contains 1–64 unique graph ids. Each id passes the server's existing +graph-id validation: 1–64 ASCII letters, digits, or hyphens, excluding +`policies`, `healthz`, `openapi`, and `graphs`. The managed issuer additionally +requires Core configuration validity, so it issues only for the intersection +of those grammars. Core graph names unsupported by existing server routing +remain unsupported; this increment does not widen routing. Each action set is nonempty, +contains no duplicate, and uses only these existing exact policy action names: +`read`, `export`, `change`, `branch_create`, `branch_delete`, `branch_merge`, +`invoke_query`, `graph_list`. Wildcards, `schema_apply`, `admin`, and unknown +actions refuse the whole token. There are no implicit action implications or +branch grants; Cedar retains branch and target-branch policy ownership. + +### Public trust and root binding + +The trust file is machine-written JSON, read once at boot, at most 64 KiB, +with no duplicate or unknown fields: + +```json +{ + "version": 1, + "canonical_root": "s3://customer/root", + "account_id": "account-1", + "cluster_id": "cluster-1", + "cluster_incarnation": "incarnation-1", + "issuer": "https://control.example", + "audience": "urn:omnigraph:data:cluster-1", + "keys": [{"kid": "<64 lowercase hex characters>", "public_key_pem": ""}] +} +``` + +There are 1–4 unique keys. Each is a valid ECDSA P-256 public key and its +declared `kid` must match the SPKI digest. Identity and origin bounds match +the credential. An invalid file or any invalid key refuses startup; an +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 +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 +realization the operator uses its validated `Cluster.status.identity`; the +mount is provisioning authority, not independent evidence of storage identity. + +Trust is immutable for the process lifetime. Rotation installs both old and +new public keys before the issuer starts using the new key. Keep an old key +installed for at least 86,430 seconds after its final issuance; retiring it +requires another controlled restart. A control-plane outage does not cause a +server-side call or change already loaded trust. Its offline bound is each +credential's actual remaining lifetime, bounded by 86,430 seconds including +the permitted issuance-clock skew. Expiry still has zero leeway. + +### Authorization and compatibility + +The bearer boundary produces an authenticated principal and immutable grant +set. Graph routing narrows it to the selected graph before registry lookup. +The common policy gate checks the token action ceiling before the existing +Cedar decision. Signed credentials require an applied policy and explicit +permit; they never inherit the static-token no-policy read default. An allowed token action is insufficient +if Cedar denies it or has no corresponding actor. No policy entity is created +from a request or token. Policy bundles explicitly enroll `principal:` +through the ordinary cluster configuration and apply path. + +| Existing route family | Required action checks | +|---|---| +| Query/read, snapshot, Blob GET/HEAD, schema/catalog, branch/commit/change-feed reads | `read` | +| Export and change-feed baseline | `export` | +| Mutate/change, conditional mutation, load/ingest | `change` | +| Stored query or mutation | `invoke_query`, then its existing inner `read` or `change` | +| Load creating a branch | Existing `branch_create` check, then `change` | +| Branch create/delete/merge | Corresponding `branch_*` action | +| Schema apply | Refused by this token profile | +| Graph registry | Server Cedar `graph_list`, plus per-result `graph_list` grant | + +The graph registry filters both served and quarantined graph ids. A grant on +one graph never exposes another. Deprecated route aliases and conditional +variants use the same checks. Merge's optional source deletion keeps its +existing separate check: a successful merge can report a denied deletion +through `branch_deleted: false` without falsely failing the durable merge. + +Explicit static break-glass credentials may coexist. The existing exact +constant-time hash match runs first; a configured static credential retains +its actor mapping, including when it contains dots. An unmatched credential +then goes to the signed-token verifier. Verification failure never re-enters +static matching or becomes anonymous. Static credentials retain existing +Cedar/default-deny semantics. + +### Managed CLI data access + +The companion issuance API supplies a data endpoint and signed credential. +`omnigraph cluster token --config DIR --graph ID --actions read,change --ttl 1h` +requests an explicit graph/action subset of the caller's current data grant. +TTL defaults to 3,600 seconds and must be 60–86,400 seconds; requested authority +never comes from control-plane admin or apply permission. `--clear` instead +forgets the selected cluster's local data credential. Token command output is +metadata only, never the credential. + +The CLI stores the result in a separate OS-keychain service under canonical +API origin plus cluster id. The entry contains the endpoint, credential, +expiry, key id, and exact grants and is validated before use. It is separate +from the control-plane session, so ordinary data requests need no API call. +Control-plane logout remains unchanged; an issued data credential survives +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. + +## Invariants + +Invariant 10 keeps actor identity at the authenticated server boundary and +retains every engine policy check. Invariant 11 bounds token parsing, trust, +key search, scope search, and time. Invariant 12 adds no authoritative token +database or mutable cache. Graph publication, accepted snapshots, recovery, +and direct-engine policy behavior are unchanged. No Lance-shaped behavior is +changed or relied on, and no Lance documentation domain applies to this HTTP +authentication work. No deny-list exception is requested. + +## Compatibility and reversibility + +Existing deployments without trust are unchanged. New credentials do not work +on old servers; they cannot degrade into static credentials. Removing the +trust flag disables signed credentials deliberately. The trust supplier must +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. + +## Alternatives + +- Online introspection or JWKS fetching adds a serving dependency and cannot + preserve the required offline contract. +- Putting credentials in each graph's static-token store adds rotation and + expiry state when signed, expiring capabilities already express the grant. +- Treating a signature as complete authorization bypasses Cedar and would + grant actions or branches the graph owner never allowed. +- Importing private managed identity formats into the server couples OSS + serving to a specific provisioning implementation; explicit boot trust + keeps that authority outside the data plane. + +## Evidence and tests + +The existing `auth_policy`, `multi_graph`, `stored_queries`, `boot_settings`, +`data_routes`, and `openapi` owners plus server unit tests passed 359 checks. +The eight verifier cases cover the issuer's shared golden signature, +tampering, wrong algorithms/keys/bindings, duplicate and oversized fields, +invalid curves, key fingerprints, exact time boundaries, unsupported versions, +and kind/assurance mismatches. A focused Core regression verifies that directory +and storage-URI snapshots report the same canonical root and state CAS. + +Protected-route tests prove the grant ceiling despite permissive Cedar policy, +Cedar denial despite a valid credential, actor-header spoofing failure, +stored-read/write double gating, cross-graph and registry isolation, and +unchanged static/unauthenticated behavior. Denied mutations leave the published +graph head unchanged. Boot coverage includes refusal before recovery opens a +graph, trust without static credentials, invalid trust, and overlapping keys. +The CLI passed 99 unit and 133 process tests; strict lint and documentation +checks passed for the implemented server and client surfaces. + +The 2026-09-05 managed pilot used the server implementation at `463290a9` and +native CLI binary with SHA-256 +`cb464cb72bb0d48ec885896e2361bee46b860b995c457cee9d33f9ca17eace0a`. +Generated evidence records real WorkOS session issuance through the API and a +per-cluster AWS KMS key, with a request for an ungranted export action refused. +The actual operator retained kubelet's final container exit on a Ready node, +completed its ordinary drain and apply, and verified that serving reported the +finalized receipt's ledger after trust and Cedar policy were installed. + +Seven real-KMS verifier cases produced the expected results: a valid read +returned 200; expired, wrong-cluster, and wrong-incarnation credentials returned +401; missing read authority, another graph's grant, and an absent Cedar actor +returned 403. With the Intent API at zero Pods, the actual CLI inserted one +node and read it back at the same graph commit with the immutable principal +actor. Its 60-second credential then produced `data_credential_expired`, and +the API was restored. This separately exercises server expiry and client cache +expiry; no credential was included in the generated CLI output. + +This qualification covers one existing cluster and one enrolled human +principal. It does not complete the companion control-plane G4 identity, auth, +and credential-transport gate or G5 managed-product gate, provide cell +infrastructure, or qualify backup/restore, distributed writer fencing, or the +full tenant adversarial matrix. Those boundaries remain outside this RFC. + +## Rollout + +Record this contract and the companion control-plane decision before code. +Implement and qualify the offline verifier, then deploy prepared trust and +policy to an existing cluster. Only then enable issuance and client access. +Keep a deliberate static break-glass path during the controlled transition. +User docs and release notes must describe the final flag, grant semantics, +expiry, and controlled key rotation. New engine or storage behavior is out of +scope, as are full cloud provisioning, online refresh, and dynamic discovery. + +## Unresolved questions + +None for the bounded wire and authorization contract. Implementation and +qualification remain separate from acceptance. + +## Decision log + +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 +the actual resolved serving root and does not import private marker formats. + +2026-09-05: Accepted by the maintainer for implementation with the explicit +wire bounds, static-first exact credential matching, required Cedar permit, +and same-snapshot canonical root accessor. Companion control-plane DEC-08-23 +owns issuance, permissions, KMS custody, and operator preparation. Qualification +remains required before claiming the implementation complete. + +2026-09-05: Retained the existing server graph-id grammar and reserved names. +This replaces the proposed 512-byte graph-id bound; the grant-count bound +remains 64. Managed issuance uses the intersection with Core configuration +identifiers rather than changing the independent server routing contract. + +2026-09-05: Completed the bounded server and CLI implementation after the +focused regressions and real WorkOS/KMS qualification above. The live pilot +used the ordinary managed drain, receipt, and readiness path, preserving +existing engine and storage behavior. The +later CLI diagnostic-redaction fix is covered by its focused regression and +does not change the successfully qualified data path. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 5f4c102dd..de3c507e5 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -38,7 +38,7 @@ issue and implementation PR are usually enough. - Do not create `pre-merge`, `final`, `v2`, `internal`, or review-ledger copies. Revise the canonical file; preserve meaningful changes in its decision log. -The next available number is **0053**; lower gaps are historical and must +The next available number is **0054**; lower gaps are historical and must not be reused (0047 and 0048 are allocated by PR #606; 0050 by the `rfc/0050-engine-crate-topology` branch). @@ -178,3 +178,4 @@ This table is the human index for the canonical RFC corpus. | [0049](0049-control-plane-seams.md) | Control-plane seams: observe, readiness witness, bounded shutdown | maintainer | accepted | complete | | [0051](0051-json-output-via-arrow.md) | JSON output via Arrow | maintainer | draft | partial | | [0052](0052-managed-control-plane-cli.md) | Managed control-plane CLI | maintainer | accepted | complete | +| [0053](0053-offline-data-token-verification.md) | Offline data-token verification | maintainer | accepted | complete | diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 4e2f7bbf0..d1dcfe11b 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -22,6 +22,11 @@ Run `omnigraph --help` for the flags supported by your installed version. The [CLI reference](reference.md) summarizes addressing, commands, configuration, and output formats. +For a managed cluster, first select it with `use` and request a scoped data +credential with `cluster token`. Then run `query` or `mutate` from that folder +with an explicit `--graph`. See [managed data access](managed-data.md) +for permissions, expiry, offline operation, and local credential clearing. + ## Create, load, and query a graph ```bash diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md new file mode 100644 index 000000000..e17dc3a6f --- /dev/null +++ b/docs/user/cli/managed-data.md @@ -0,0 +1,62 @@ +# Managed data access + +This guide covers data credentials for an existing managed cluster. Complete +[managed login and cluster selection](reference.md#managed-cluster-commands) +first; data authority is separate from that control-plane session. + +After selecting a managed cluster with `use`, request the data permissions +needed for the intended graph operations: + +```bash +omnigraph cluster token --graph knowledge --actions read,change,invoke_query --ttl 1h +omnigraph query find_person --graph knowledge --params '{"name":"Alice"}' --json +omnigraph mutate add_person --graph knowledge --params '{"name":"Alice"}' --json +``` + +The example invokes stored queries, which require `invoke_query` as well as +`read` or `change`. Ad-hoc query and mutation source uses `read` or `change` +respectively. Every request also passes the graph's Cedar policy; a token +cannot grant an action the policy denies. Control-plane admin or apply +permission does not confer data permission. + +`cluster token` requires explicit `--graph` and `--actions`. Accepted actions +are `read`, `export`, `change`, `branch_create`, `branch_delete`, `branch_merge`, +`invoke_query`, and `graph_list`; duplicate actions, wildcards, `admin`, and +`schema_apply` refuse. `--ttl` accepts seconds or an `s`, `m`, `h`, or `d` +suffix, defaults to one hour, and must be between 60 seconds and 24 hours. +The service can shorten the requested lifetime. Issuer clock tolerance is +30 seconds; a credential is never accepted after its stated expiry. +`--json` and human output +contain metadata only, never the signed credential. + +The CLI saves one data credential per API origin and cluster in a separate +OS-keychain entry, replacing that cluster's previous entry. It contains the +fixed data endpoint, expiry, key id, actor, and exact grants. There is no +plaintext cache and no fallback to a control-plane session or a named-server +token. Automation can use the origin-bound control credential to run this +command with an available keychain; unattended clients needing a raw token +use the issuance API directly. + +Managed `query` and `mutate` read `.omnigraph/context` only in the current +directory and always require `--graph`. After `cluster token --config DIR`, +run data commands from `DIR`; no parent directory is searched. Ordinary data +requests go directly to the cached endpoint without contacting the control +API. They keep working during an API outage until the token expires or its +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. + +`cluster token --clear [--config DIR]` forgets that cluster's local data +entry, independently of the control-plane session. Do not combine `--clear` +with `--graph`, `--actions`, or `--ttl`. Clearing is not server revocation: +copies remain usable until expiry or signing-key retirement. Likewise, +`logout --api` revokes only the control-plane login and does not invalidate +already issued data credentials. diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index a405bd551..d7d323db3 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -277,7 +277,7 @@ Status and history reads exit 0 when retrieved successfully. Abandoning a saved plan preserves its converged result and exits 0. Managed apply does not prompt for an additional approval: the API checks the authenticated caller's permissions. `--as`, `--server`, `--profile`, `--graph`, `--store`, and the -global `--cluster` selector do not apply to managed commands. +global `--cluster` selector do not apply to these managed cluster operations. For unattended execution, provide an explicitly scoped automation token and its API origin together: @@ -299,6 +299,12 @@ Without a context, existing direct cluster commands behave as before. `approve`, `observe`, `refresh`, and `force-unlock`, refuse when a managed context is present. API failures never trigger direct execution. +## Managed data access + +Use `cluster token` to cache scoped data authority, then `query` or `mutate` +with `--graph` from the managed folder. See [managed data access](managed-data.md) +for permissions, offline behavior, expiry, and local credential clearing. + ## Confirmation rules `cleanup` changes nothing until `--confirm` is present. Destructive operations diff --git a/docs/user/index.md b/docs/user/index.md index b0b539e13..a5be37de0 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -29,6 +29,7 @@ deployment. - [CLI guide](cli/index.md) - [CLI reference](cli/reference.md) +- [Managed data credentials and queries](cli/managed-data.md) - [HTTP server](operations/server.md) - [Troubleshooting](operations/troubleshooting.md) diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 966964136..73782fbf8 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -40,7 +40,7 @@ breaking the directory's config can change where boot looks for applied state. ## Authentication -Choose one token source: +Choose one static token source: ```bash # One token, actor name "default" @@ -65,12 +65,52 @@ Authorization: Bearer secret-a The token selects the actor used by authorization and commit attribution. Clients cannot claim another actor. See [Authorization and actors](policy.md). -A server with neither tokens nor policy refuses to start unless you explicitly +A server with neither static tokens, signed-token trust, nor policy refuses to start unless you explicitly pass `--unauthenticated` (or set `OMNIGRAPH_UNAUTHENTICATED=1`). Use that only on a trusted development network. Tokens without a policy allow only the `read` action. Stored-query invocation, export, graph listing, writes, and other actions remain denied. +### Signed data credentials + +To accept short-lived credentials from an issuer, mount its public trust file +and select it explicitly: + +```bash +omnigraph-server --cluster s3://company-data/company-brain \ + --data-token-trust /run/omnigraph/data-token-trust.json +``` + +The file binds public signing keys to the exact storage root, issuer, account, +cluster id, and cluster incarnation. Invalid trust or a root mismatch refuses +startup before graphs open. Trust alone requires bearer authentication. The +server verifies tokens locally; it does not contact an identity or control +service. The provisioning operator owns supplying the correct identity binding. +The [trust and credential format](../../rfcs/0053-offline-data-token-verification.md#public-trust-and-root-binding) +defines the machine-written file. + +Signed credentials use the actor `principal:`. Apply a +Cedar policy permitting that exact actor through the ordinary cluster loop +before using the credential. Its graph/action grants only narrow that policy: +no policy, an unknown actor, or an action absent from either permission source +is denied. A caller cannot change its actor through request headers or JSON. +Graph listing reveals only graphs with an explicit `graph_list` grant. + +Tokens live for 60–86,400 seconds from issuance. The server permits an issuance +clock up to 30 seconds ahead, so at most 86,430 seconds can remain on admission. +Expiry has no grace period. Logout or a permission change at the issuer does +not revoke an issued token; already accepted operations can finish after +expiry. Stored-query calls need `invoke_query` plus `read` or `change` for the +body. Schema changes still use `cluster apply`; data tokens cannot grant +`schema_apply` or `admin`. + +Static credentials can coexist for operator recovery. An exact configured +static credential keeps its existing authority, including credentials with +dots; an invalid signed credential never falls back to static or anonymous +access. Restart to change public trust. Install new and old keys together +before issuing with a new key, and retain the old key for at least 86,430 +seconds after its final issuance before removing it with another restart. + ## Route families | Route family | Purpose |