diff --git a/Cargo.lock b/Cargo.lock index bb36dbb4f..92a2533c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5482,6 +5482,7 @@ name = "omnigraph-cli" version = "0.11.0" dependencies = [ "assert_cmd", + "base64 0.22.1", "clap", "color-eyre", "futures", diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index 1a4368247..016ce1659 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -1770,8 +1770,8 @@ pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput { /// One entry in the response from `GET /graphs`. Cluster operators /// consume this list to discover which graphs the server is currently -/// serving. The shape is intentionally minimal — `graph_id` and `uri` -/// are the only fields a routing client needs. +/// serving. This legacy metadata includes the storage `uri`; identity-only +/// existence discovery uses [`GraphDiscoveryEntry`] instead. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct GraphInfo { pub graph_id: String, @@ -1791,6 +1791,22 @@ pub struct GraphListResponse { pub quarantined: Vec, } +/// A graph's existence, without storage, schema, data, or serving metadata. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct GraphDiscoveryEntry { + pub graph_id: String, + /// Currently the graph identifier; no separate display name is configured. + pub display_name: String, +} + +/// Authenticated minimal inventory from `GET /graphs/discovery`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct GraphDiscoveryResponse { + pub graphs: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/omnigraph-cli/Cargo.toml b/crates/omnigraph-cli/Cargo.toml index 98f9ccc26..015db0e17 100644 --- a/crates/omnigraph-cli/Cargo.toml +++ b/crates/omnigraph-cli/Cargo.toml @@ -20,6 +20,7 @@ omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.11.0" } omnigraph-policy = { path = "../omnigraph-policy", version = "0.11.0" } omnigraph-server = { path = "../omnigraph-server", version = "0.11.0" } clap = { workspace = true } +base64 = { workspace = true } color-eyre = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["raw_value"] } diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 13f2f2bd6..8141e7e9e 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -587,14 +587,14 @@ pub(crate) enum ClusterCommand { #[command(flatten)] managed: ManagedRunArgs, }, - /// Cache a scoped data credential for this managed cluster, or forget it locally. + /// Cache an identity credential for this 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")] + /// Legacy restricted profile: exact comma-separated actions; requires --graph. + #[arg(long, 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")] @@ -771,6 +771,9 @@ pub(crate) enum GraphsCommand { List { #[arg(long)] json: bool, + /// Minimal authenticated graph existence; requires an identity credential. + #[arg(long)] + discovery: bool, }, } diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index d604cbe4f..4df52b1f2 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -33,11 +33,11 @@ use omnigraph_api_types::{ BranchOutcomeOutput, ChangeBaselineOutput, ChangeBaselineRecord, ChangeBaselineRequest, ChangeFeedOutput, ChangeOpOutput, ChangeOutput, ChangeRequest, CommitChangesOutput, CommitListOutput, CommitOutput, EntityKindOutput, ErrorOutput, ExportRequest, - GraphBatchLoadOutput, GraphListResponse, IngestOutput, IngestRequest, InvokeStoredQueryRequest, - QueryRequest, ReadOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, - branch_list_read_output, change_baseline_output, change_feed_output, change_scope, - commit_changes_output, commit_output, ingest_receipt_output, read_output, schema_apply_output, - snapshot_payload, + GraphBatchLoadOutput, GraphDiscoveryResponse, GraphListResponse, IngestOutput, IngestRequest, + InvokeStoredQueryRequest, QueryRequest, ReadOutput, SchemaApplyOutput, SchemaApplyRequest, + SchemaOutput, SnapshotOutput, branch_list_read_output, change_baseline_output, + change_feed_output, change_scope, commit_changes_output, commit_output, ingest_receipt_output, + read_output, schema_apply_output, snapshot_payload, }; use omnigraph_compiler::catalog::Catalog; use omnigraph_compiler::query::ast::BranchWrite; @@ -52,7 +52,7 @@ use crate::blob_cli::{ }; use crate::cli::CliLoadMode; use crate::helpers::{ - apply_bearer_token, apply_server_flag, branch_statement_change_request, + RemoteErrorCli, apply_bearer_token, apply_server_flag, branch_statement_change_request, branch_statement_query_request, 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_bounded, remote_response_json_bounded, remote_url, resolve_cli_actor, @@ -154,6 +154,14 @@ 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 { + Self::managed_url(remote_url(endpoint, &["graphs", graph], &[])?, token) + } + + pub(crate) fn managed_registry(endpoint: &str, token: String) -> Result { + Self::managed_url(endpoint.to_owned(), token) + } + + fn managed_url(base_url: String, token: String) -> Result { Ok(Self::Remote { http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) @@ -161,7 +169,7 @@ impl GraphClient { .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(30)) .build()?, - base_url: remote_url(endpoint, &["graphs", graph], &[])?, + base_url, token: Some(token), response_limit: Some(8 * 1024 * 1024), }) @@ -674,7 +682,7 @@ impl GraphClient { if !status.is_success() { let text = response.text().await?; if let Ok(error) = serde_json::from_str::(&text) { - bail!(error.error); + return Err(RemoteErrorCli { output: error }.into()); } bail!("server returned {}: {}", status, text); } @@ -1362,7 +1370,7 @@ impl GraphClient { if !status.is_success() { let text = response.text().await?; if let Ok(error) = serde_json::from_str::(&text) { - bail!(error.error); + return Err(RemoteErrorCli { output: error }.into()); } bail!("server returned {}: {}", status, text); } @@ -1574,6 +1582,30 @@ impl GraphClient { ), } } + + /// Minimal existence inventory. No fallback to the metadata-bearing catalog. + pub(crate) async fn discover_graphs(&self) -> Result { + match self { + Self::Remote { + http, + base_url, + token, + response_limit, + } => { + remote_json_bounded( + http, + Method::GET, + remote_url(base_url, &["graphs", "discovery"], &[])?, + None, + token.as_deref(), + None, + response_limit.or(Some(8 * 1024 * 1024)), + ) + .await + } + Self::Embedded { .. } => bail!("graph discovery requires a server"), + } + } } fn validate_content_range( diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index 1ada1d3d9..c9da536ae 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -455,6 +455,21 @@ impl std::fmt::Display for PreconditionFailedCli { impl std::error::Error for PreconditionFailedCli {} +/// Preserve a typed server refusal through the command dispatch so JSON +/// callers retain its code and detail fields instead of parsing a message. +#[derive(Debug)] +pub(crate) struct RemoteErrorCli { + pub(crate) output: ErrorOutput, +} + +impl std::fmt::Display for RemoteErrorCli { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.output.error) + } +} + +impl std::error::Error for RemoteErrorCli {} + /// Build the typed CAS-lost error for the embedded transport, mirroring the /// structured body a server would have returned so `--json` output is /// transport-uniform. `message` is the engine error's own `Display` text, so @@ -584,7 +599,7 @@ pub(crate) async fn remote_response_json_bounded( if error.precondition_failure.is_some() { return Err(PreconditionFailedCli { output: error }.into()); } - bail!(error.error); + return Err(RemoteErrorCli { output: error }.into()); } bail!("server returned {}: {}", status, text); } diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 6abf68dc5..de83beabf 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -138,7 +138,7 @@ fn installed_file_is_current(installed: &fs::File, path: &std::path::Path) -> Re #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; - let cli = { + let (cli, json) = { let raw_args = rewrite_deprecated_argv(std::env::args_os().collect()); let matches = Cli::command() .arg( @@ -149,8 +149,37 @@ async fn main() -> Result<()> { .help("Print version"), ) .get_matches_from(raw_args); - Cli::from_arg_matches(&matches)? + let mut command_matches = &matches; + while let Some((_, child)) = command_matches.subcommand() { + command_matches = child; + } + let json = command_matches + .try_get_one::("json") + .ok() + .flatten() + .copied() + .unwrap_or(false) + || command_matches + .try_get_one::("format") + .ok() + .flatten() + == Some(&ReadOutputFormat::Json); + (Cli::from_arg_matches(&matches)?, json) }; + match run(cli).await { + Err(error) if json => { + if let Some(remote) = error.downcast_ref::() { + print_json(&remote.output)?; + std::io::stdout().flush()?; + std::process::exit(1); + } + Err(error) + } + result => result, + } +} + +async fn run(cli: Cli) -> Result<()> { if let Some(result) = managed::dispatch(&cli).await { let code = result.emit()?; if code != 0 { @@ -1744,14 +1773,36 @@ async fn main() -> Result<()> { } }, Command::Graphs { command } => match command { - GraphsCommand::List { json } => { - // Registry scope (RFC-011): the bare server base URL, resolved - // synchronously — the async D7 require-graph probe cannot run - // here, and no `/graphs/` is ever appended. - let client = client::GraphClient::resolve_registry( - cli.server.as_deref(), - cli.profile.as_deref(), - )?; + GraphsCommand::List { json, discovery } => { + let (client, discovery) = if let Some(client) = managed_data { + (client, true) + } else { + // Explicit operator addressing retains the legacy catalog + // unless discovery is explicitly requested. Token bytes do + // not choose configuration or change static-token behavior. + ( + client::GraphClient::resolve_registry( + cli.server.as_deref(), + cli.profile.as_deref(), + )?, + discovery, + ) + }; + if discovery { + let payload = client.discover_graphs().await?; + if json { + print_json(&payload)?; + } else { + for entry in payload.graphs { + if entry.display_name == entry.graph_id { + println!("{}", entry.graph_id); + } else { + println!("{}\t{}", entry.graph_id, entry.display_name); + } + } + } + return Ok(()); + } let payload = client.list_graphs().await?; if json { print_json(&payload)?; diff --git a/crates/omnigraph-cli/src/managed/data.rs b/crates/omnigraph-cli/src/managed/data.rs index 9038c3ae6..3f5b2df84 100644 --- a/crates/omnigraph-cli/src/managed/data.rs +++ b/crates/omnigraph-cli/src/managed/data.rs @@ -1,8 +1,10 @@ -//! RFC 0053: cached data authority has its own keychain namespace and transport. +//! Cached data credentials have a separate 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::cli::{Cli, Command, GraphsCommand}; use crate::client::GraphClient; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeSet; @@ -39,9 +41,32 @@ struct Credential { expires_at: String, kid: String, actor: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cluster_incarnation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] grants: Vec, } +/// Parsed only to reject mismatched issuance/cache metadata, never to select +/// configuration or grant authority. The server still verifies the signature. +fn parse_identity_claims( + token: &str, +) -> Option { + if token.len() > MAX_TOKEN { + return None; + } + let mut parts = token.split('.'); + let _header = parts.next()?; + let claims = parts.next()?; + let _signature = parts.next()?; + if parts.next().is_some() { + return None; + } + let claims: omnigraph_server::data_tokens::IdentityTokenClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims).ok()?).ok()?; + (claims.version == 2).then_some(claims) +} + fn key(context: &Context) -> String { format!("{}/clusters/{}", context.api, context.cluster) } @@ -97,7 +122,7 @@ 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 + if !matches!(self.version, 1 | 2) || self.api != context.api || self.cluster_id != context.cluster || !canonical_origin(&self.endpoint).is_ok_and(|o| o == self.endpoint) @@ -131,11 +156,50 @@ impl Credential { "the data credential has expired; mint a new cluster token", )); } - validate_grants(&self.grants) + if self.version == 1 { + if self.cluster_incarnation.is_some() || parse_identity_claims(&self.token).is_some() { + return Err(invalid()); + } + validate_grants(&self.grants) + } else { + let claims = parse_identity_claims(&self.token).ok_or_else(invalid)?; + let header = self.token.split('.').next().ok_or_else(invalid)?; + let header: omnigraph_server::data_tokens::DataTokenHeader = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + if !self.grants.is_empty() + || claims.iss != self.api + || claims.cluster_id != self.cluster_id + || self.cluster_incarnation.as_deref() != Some(claims.cluster_incarnation.as_str()) + || claims.aud != format!("urn:omnigraph:data:{}", self.cluster_id) + || self.actor != format!("principal:{}", claims.sub) + || i64::try_from(claims.exp).ok() != Some(expires.unix_timestamp()) + || header.kid != self.kid + || header.typ != "JWT" + || header.alg != "ES256" + || !claims + .exp + .checked_sub(claims.iat) + .is_some_and(|ttl| (60..=86400).contains(&ttl)) + || claims.iat + > u64::try_from(now.unix_timestamp()) + .unwrap_or_default() + .saturating_add(30) + { + return Err(invalid()); + } + Ok(()) + } } 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}) + let mut metadata = json!({"cluster_id":self.cluster_id,"endpoint":self.endpoint,"expires_at":self.expires_at,"kid":self.kid,"actor":self.actor}); + if self.version == 1 { + metadata["grants"] = json!(self.grants); + } else { + metadata["version"] = json!(2); + } + metadata } } @@ -195,6 +259,16 @@ async fn mint( api: &Api, grant: Grant, ttl: u64, +) -> Result { + mint_profile(store, context, api, Some(grant), ttl).await +} + +async fn mint_profile( + store: &impl Store, + context: &Context, + api: &Api, + grant: Option, + ttl: u64, ) -> Result { // Fail early if the platform cannot access its credential store. let _ = store.get(&key(context))?; @@ -202,12 +276,34 @@ async fn mint( .request( Method::POST, &format!("/v1/clusters/{}/tokens", context.cluster), - Some(&json!({"grants":[grant],"ttl_seconds":ttl})), + Some(&match &grant { + Some(grant) => json!({"grants":[grant],"ttl_seconds":ttl}), + None => json!({"version":2,"ttl_seconds":ttl}), + }), None, ) .await?; super::cluster_matches(&body, &context.cluster)?; let data = &body["data"]; + if grant.is_some() + && (data.get("version").is_some_and(|version| *version != 1) + || data["token"] + .as_str() + .and_then(parse_identity_claims) + .is_some()) + { + // A response cannot upgrade an explicit restricted request, even if + // it also echoes the requested grants beside an identity credential. + return Err(Failure::protocol()); + } + if grant.is_none() + && (data["version"] != 2 + || ["grants", "roles", "actions", "groups", "policy"] + .iter() + .any(|field| data.get(field).is_some())) + { + return Err(Failure::protocol()); + } let string = |field| { data.get(field) .and_then(Value::as_str) @@ -215,7 +311,7 @@ async fn mint( .ok_or_else(Failure::protocol) }; let credential = Credential { - version: 1, + version: if grant.is_some() { 1 } else { 2 }, api: context.api.clone(), cluster_id: context.cluster.clone(), endpoint: string("endpoint")?, @@ -223,15 +319,30 @@ async fn mint( expires_at: string("expires_at")?, kid: string("kid")?, actor: string("actor")?, - grants: serde_json::from_value(data["grants"].clone()).map_err(|_| Failure::protocol())?, + cluster_incarnation: if grant.is_none() { + Some( + body["meta"]["incarnation"] + .as_str() + .ok_or_else(Failure::protocol)? + .to_owned(), + ) + } else { + None + }, + grants: if grant.is_some() { + serde_json::from_value(data["grants"].clone()).map_err(|_| Failure::protocol())? + } else { + Vec::new() + }, }; 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) + if grant.as_ref().is_some_and(|grant| { + 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()); } @@ -270,20 +381,28 @@ pub(super) async fn token( } return self::clear(&auth::DATA_STORE, context); } - let grant = requested_grant(cli.graph.as_deref(), actions)?; + let grant = if actions.is_some() { + Some(requested_grant(cli.graph.as_deref(), actions)?) + } else { + if cli.graph.is_some() { + return Err(Failure::refused( + "token_profile_conflict", + "identity credentials do not select a graph; use --graph on the graph operation, or pair it with --actions for the legacy restricted profile", + )); + } + None + }; 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 + match grant { + Some(grant) => mint(&auth::DATA_STORE, context, &api, grant, ttl.unwrap_or(3600)).await, + None => mint_profile(&auth::DATA_STORE, context, &api, None, ttl.unwrap_or(3600)).await, + } } -fn load( - store: &impl Store, - context: &Context, - graph: &str, - required: &[&str], -) -> Result { +fn load_credential(store: &impl Store, context: &Context) -> Result { let raw = store.get(&key(context))?.ok_or_else(|| { Failure::refused( "data_credential_required", @@ -294,13 +413,33 @@ fn load( return Err(invalid()); } let credential: Credential = serde_json::from_str(&raw).map_err(|_| invalid())?; + if credential.version == 2 + && serde_json::from_str::(&raw) + .map_err(|_| invalid())? + .get("grants") + .is_some() + { + return 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)) - }) { + Ok(credential) +} + +fn load( + store: &impl Store, + context: &Context, + graph: &str, + required: &[&str], +) -> Result { + let credential = load_credential(store, context)?; + if credential.version == 1 + && !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", @@ -317,7 +456,14 @@ fn load( fn skips_context(cli: &Cli) -> bool { cli.direct - || !matches!(cli.command, Command::Query { .. } | Command::Mutate { .. }) + || !matches!( + cli.command, + Command::Query { .. } + | Command::Mutate { .. } + | Command::Graphs { + command: GraphsCommand::List { .. } + } + ) || cli.server.is_some() || cli.profile.is_some() || cli.store.is_some() @@ -359,6 +505,31 @@ fn resolve( "folder context competes with OMNIGRAPH_PROFILE or an operator default target; select the intended ordinary target explicitly, use --direct for ordinary ambient resolution, or clear the competing ambient target to use this managed folder", )); } + if matches!(cli.command, Command::Graphs { .. }) { + scope(cli)?; + if cli.graph.is_some() { + return Err(Failure::refused( + "graph_scope_conflict", + "graphs list enumerates a cluster; omit --graph", + )); + } + let credential = load_credential(store, &context)?; + if credential.version != 2 { + return Err(Failure::refused( + "data_profile_unsupported", + "managed graph discovery requires an identity credential; legacy restrictions are not widened", + )); + } + return GraphClient::managed_registry(&credential.endpoint, credential.token) + .map(Some) + .map_err(|_| { + Failure::new( + "transport_failed", + "could not initialize the managed discovery client", + 1, + ) + }); + } let (action, named) = match &cli.command { Command::Query { query, @@ -395,6 +566,9 @@ pub(crate) fn client(cli: &Cli) -> std::result::Result, Outp *json || matches!(format, Some(crate::read_format::ReadOutputFormat::Json)) } Command::Mutate { json, .. } => *json, + Command::Graphs { + command: GraphsCommand::List { json, .. }, + } => *json, _ => false, }; let result = std::env::current_dir() diff --git a/crates/omnigraph-cli/src/managed/data/tests.rs b/crates/omnigraph-cli/src/managed/data/tests.rs index fe8da8b8f..f52672adb 100644 --- a/crates/omnigraph-cli/src/managed/data/tests.rs +++ b/crates/omnigraph-cli/src/managed/data/tests.rs @@ -26,6 +26,7 @@ fn credential(context: &Context, endpoint: &str) -> Credential { .unwrap(), kid: "a".repeat(64), actor: "principal:alice".into(), + cluster_incarnation: None, grants: vec![Grant { graph_id: "knowledge".into(), actions: vec!["read".into(), "change".into(), "invoke_query".into()], @@ -33,6 +34,28 @@ fn credential(context: &Context, endpoint: &str) -> Credential { } } +fn identity_credential(context: &Context, endpoint: &str) -> Credential { + let mut credential = credential(context, endpoint); + let now = OffsetDateTime::now_utc().unix_timestamp(); + credential.version = 2; + credential.grants.clear(); + credential.cluster_incarnation = Some("incarnation-a".into()); + credential.expires_at = OffsetDateTime::from_unix_timestamp(now + 3600) + .unwrap() + .format(&Rfc3339) + .unwrap(); + let header = json!({"typ":"JWT","alg":"ES256","kid":credential.kid}); + let claims = json!({"version":2,"iss":context.api,"aud":format!("urn:omnigraph:data:{}", context.cluster), + "sub":"alice","account_id":"account-a","cluster_id":context.cluster,"cluster_incarnation":"incarnation-a", + "principal_kind":"human","assurance":"verified_human","iat":now,"exp":now+3600,"jti":"test-credential"}); + credential.token = format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + credential +} + fn save(store: &MemoryStore, context: &Context, credential: &Credential) { store .put(&key(context), &serde_json::to_string(credential).unwrap()) @@ -84,6 +107,7 @@ fn token_arguments_bound_authority_and_keep_direct_compatibility() { 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"]).is_ok()); assert!( Cli::try_parse_from([ "omnigraph", @@ -236,6 +260,96 @@ async fn minted_data_credential_is_separate_and_works_after_api_stops() { ); } +#[tokio::test] +async fn identity_issuance_caches_no_permissions_and_discovers_without_control_calls() { + let discovery = json!({"graphs":[{"graph_id":"hidden","display_name":"hidden"}]}); + let data = IntentApiFixture::new(vec![IntentReply::json(200, discovery.clone())]); + let mut context = context(); + let cp = IntentApiFixture::with_origin(|origin| { + context.api = origin.to_owned(); + let credential = identity_credential(&context, &data.origin); + let mut response = credential.metadata(); + response["token"] = json!(credential.token); + vec![IntentReply::json( + 200, + json!({"data":response,"meta":{"cluster_id":context.cluster,"incarnation":"incarnation-a"}}), + )] + }); + let store = MemoryStore::default(); + let api = Api::new(cp.origin.clone(), Some("control-session".into())).unwrap(); + let output = mint_profile(&store, &context, &api, None, 3600) + .await + .unwrap(); + assert_eq!(output["data"]["version"], 2); + assert!(output["data"].get("grants").is_none()); + assert!(output["data"].get("token").is_none()); + assert_eq!( + cp.requests()[0].body, + json!({"version":2,"ttl_seconds":3600}) + ); + cp.assert_complete(); + drop(cp); + let saved: Value = serde_json::from_str(&store.get(&key(&context)).unwrap().unwrap()).unwrap(); + assert!(saved.get("grants").is_none()); + assert!( + load(&store, &context, "any-graph", &["schema_apply"]).is_ok(), + "Cedar, not the local cache, decides permission" + ); + let dir = tempfile::tempdir().unwrap(); + super::super::save_context(dir.path(), &context).unwrap(); + let cli = Cli::try_parse_from(["omnigraph", "graphs", "list", "--json"]).unwrap(); + let client = resolve(&cli, dir.path(), &store, || Ok(false)) + .unwrap() + .unwrap(); + let result = client.discover_graphs().await.unwrap(); + assert_eq!(serde_json::to_value(result).unwrap(), discovery); + assert_eq!(data.requests()[0].path, "/graphs/discovery"); + data.assert_complete(); + save(&store, &context, &credential(&context, &data.origin)); + let failure = resolve(&cli, dir.path(), &store, || Ok(false)) + .err() + .unwrap(); + assert_eq!(failure.body["type"], "data_profile_unsupported"); +} + +#[tokio::test] +async fn identity_issuance_rejects_wrong_profile_and_authority_without_cache_replacement() { + for field in ["version", "grants", "roles", "actor", "incarnation"] { + let mut context = context(); + let cp = IntentApiFixture::with_origin(|origin| { + context.api = origin.to_owned(); + let credential = identity_credential(&context, "https://data.example"); + let mut response = credential.metadata(); + response["token"] = json!(credential.token); + let mut envelope = json!({"data":response,"meta":{"cluster_id":context.cluster,"incarnation":"incarnation-a"}}); + match field { + "version" => envelope["data"]["version"] = json!(1), + "grants" => envelope["data"]["grants"] = json!([]), + "roles" => envelope["data"]["roles"] = json!(["admin"]), + "actor" => envelope["data"]["actor"] = json!("principal:other"), + _ => envelope["meta"]["incarnation"] = json!("other"), + } + vec![IntentReply::json(200, envelope)] + }); + let store = MemoryStore::default(); + store + .put(&key(&context), "existing-restricted-credential") + .unwrap(); + let api = Api::new(cp.origin.clone(), Some("control-session".into())).unwrap(); + assert!( + mint_profile(&store, &context, &api, None, 3600) + .await + .is_err(), + "accepted {field}" + ); + assert_eq!( + store.get(&key(&context)).unwrap().as_deref(), + Some("existing-restricted-credential") + ); + cp.assert_complete(); + } +} + #[test] fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { let context = context(); @@ -274,6 +388,10 @@ fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { ("endpoint", json!("http://data.example")), ("endpoint", json!("https://user:secret@data.example")), ("token", json!("a.b")), + ( + "token", + json!(identity_credential(&context, "https://data.example").token), + ), ("token", json!("x".repeat(MAX_TOKEN + 1))), ("kid", json!("not-a-fingerprint")), ( @@ -315,7 +433,13 @@ fn cached_authority_refuses_wrong_bindings_expiry_and_extra_fields() { #[tokio::test] async fn invalid_issuance_never_replaces_cached_authority() { - for corruption in ["extra-action", "foreign-endpoint", "oversize-token"] { + for corruption in [ + "extra-action", + "foreign-endpoint", + "oversize-token", + "profile-upgrade", + "hidden-profile-upgrade", + ] { let mut context = context(); let valid = credential(&context, "https://data.example"); let mut response = valid.metadata(); @@ -325,6 +449,11 @@ async fn invalid_issuance_never_replaces_cached_authority() { "foreign-endpoint" => { response["endpoint"] = json!("https://user:password@data.example/path") } + "profile-upgrade" => response["version"] = json!(2), + "hidden-profile-upgrade" => { + response["token"] = + json!(identity_credential(&context, "https://data.example").token) + } _ => response["token"] = json!("x".repeat(MAX_TOKEN + 1)), } let cp = IntentApiFixture::new(vec![IntentReply::json( @@ -508,7 +637,10 @@ fn managed_data_issue_633_explicit_and_unrelated_commands_skip_context() { vec!["commit", "show", "commit-a", "--uri", "file:///scratch"], vec!["commit", "show", "commit-a", "--store", "file:///scratch"], vec!["commit", "changes", "commit-a"], - vec!["graphs", "list"], + vec!["graphs", "list", "--server", "legacy"], + vec!["graphs", "list", "--server", "legacy", "--discovery"], + vec!["graphs", "list", "--profile", "legacy"], + vec!["graphs", "list", "--direct"], vec!["alias", "people"], vec!["queries", "list"], vec!["queries", "validate"], @@ -767,6 +899,8 @@ async fn managed_data_errors_redact_reflected_credentials_including_precondition &error.downcast_ref::().unwrap().output, ) .unwrap() + } else if let Some(remote) = error.downcast_ref::() { + serde_json::to_string(&remote.output).unwrap() } else { error.to_string() }; diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index 734d2c064..6345d835b 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -2245,6 +2245,127 @@ fn remote_if_commit_fails_closed_against_an_older_server() { ); } +#[test] +fn remote_json_errors_preserve_server_codes_and_details() { + use support::managed_http::{IntentApiFixture, IntentReply}; + + for (arguments, status, body, exit) in [ + ( + vec!["query", "restricted"], + 403, + serde_json::json!({"error":"read denied by current policy","code":"forbidden"}), + 1, + ), + ( + vec!["mutate", "restricted"], + 403, + serde_json::json!({"error":"change denied by current policy","code":"forbidden"}), + 1, + ), + ( + vec!["schema", "show"], + 403, + serde_json::json!({"error":"schema read denied by current policy","code":"forbidden"}), + 1, + ), + ( + vec!["mutate", "restricted"], + 409, + serde_json::json!({ + "error":"request exceeds the write budget", + "resource_limit":{"resource":"entities","limit":100,"actual":101} + }), + 1, + ), + ( + vec!["mutate", "restricted", "--if-commit", "head-before"], + 412, + serde_json::json!({ + "error":"graph head changed", + "precondition_failure":{"expected":"head-before","actual":"head-after"} + }), + 4, + ), + ] { + let formats: &[&[&str]] = if arguments[0] == "query" { + &[&["--json"], &["--format", "json"]] + } else { + &[&["--json"]] + }; + for format in formats { + let server = IntentApiFixture::new(vec![IntentReply::json(status, body.clone())]); + let output = cli() + .env_remove("OMNIGRAPH_BEARER_TOKEN") + .args(["--server", &server.origin, "--graph", "knowledge"]) + .args(&arguments) + .args(*format) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(exit), + "{arguments:?} {format:?}: {output:?}" + ); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap_or_else(|error| { + panic!("{arguments:?} {format:?} lost structured HTTP {status}: {error}; {output:?}") + }), + body, + "{arguments:?} {format:?} must preserve the server's complete error contract" + ); + assert!( + output.stderr.is_empty(), + "{arguments:?} {format:?}: {output:?}" + ); + server.assert_complete(); + } + } +} + +#[test] +fn remote_human_and_invalid_json_errors_remain_diagnostics() { + use support::managed_http::{IntentApiFixture, IntentReply}; + + for (json, body, expected) in [ + ( + false, + r#"{"error":"read denied by current policy","code":"forbidden"}"#, + "read denied by current policy", + ), + ( + true, + "upstream temporarily unavailable", + "server returned 403", + ), + ] { + let server = IntentApiFixture::new(vec![IntentReply { + status: 403, + headers: Vec::new(), + body: body.as_bytes().to_vec(), + }]); + let mut command = cli(); + command.env_remove("OMNIGRAPH_BEARER_TOKEN").args([ + "--server", + &server.origin, + "--graph", + "knowledge", + "query", + "restricted", + ]); + if json { + command.arg("--json"); + } + let output = command.output().unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty(), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains(expected), + "{output:?}" + ); + server.assert_complete(); + } +} + #[test] fn change_resolves_uri_and_default_branch_from_store_scope() { // RFC-011: a mutate resolves its graph from `--store` and defaults the diff --git a/crates/omnigraph-cli/tests/cli_schema_config.rs b/crates/omnigraph-cli/tests/cli_schema_config.rs index 1a86ae4c2..04dee23ab 100644 --- a/crates/omnigraph-cli/tests/cli_schema_config.rs +++ b/crates/omnigraph-cli/tests/cli_schema_config.rs @@ -629,6 +629,59 @@ fn graphs_subcommand_help_lists_list_only() { ); } +#[test] +fn explicit_graph_discovery_preserves_jwt_shaped_static_catalog_and_skips_context() { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use support::managed_http::{IntentApiFixture, IntentReply}; + let directory = tempdir().unwrap(); + fs::create_dir(directory.path().join(".omnigraph")).unwrap(); + fs::write( + directory.path().join(".omnigraph/context"), + "malformed context", + ) + .unwrap(); + let claims = serde_json::json!({"version":2,"iss":"https://issuer.example","aud":"urn:omnigraph:data:c", + "sub":"alice","account_id":"a","cluster_id":"c","cluster_incarnation":"i", + "principal_kind":"human","assurance":"verified_human","iat":1,"exp":3601,"jti":"j"}); + let token = format!( + "header.{}.signature", + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + for discovery in [false, true] { + let reply = if discovery { + serde_json::json!({"graphs":[{"graph_id":"alpha","display_name":"alpha"}]}) + } else { + serde_json::json!({"graphs":[{"graph_id":"alpha","uri":"file:///private/alpha"}]}) + }; + let server = IntentApiFixture::new(vec![IntentReply::json(200, reply.clone())]); + let mut command = cli(); + command + .current_dir(directory.path()) + .env("OMNIGRAPH_BEARER_TOKEN", &token) + .args(["graphs", "list", "--server", &server.origin, "--json"]); + if discovery { + command.arg("--discovery"); + } + let output = output_success(&mut command); + let actual: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(actual, reply); + assert_eq!( + server.requests()[0].path, + if discovery { + "/graphs/discovery" + } else { + "/graphs" + } + ); + assert_eq!( + server.requests()[0].headers["authorization"], + format!("Bearer {token}") + ); + server.assert_complete(); + } +} + #[test] fn init_with_store_flag_errors_instead_of_ignoring_it() { // `init` takes its target as a required positional URI and never reads diff --git a/crates/omnigraph-cli/tests/support/managed_http.rs b/crates/omnigraph-cli/tests/support/managed_http.rs index 50e24c080..f64ef9e71 100644 --- a/crates/omnigraph-cli/tests/support/managed_http.rs +++ b/crates/omnigraph-cli/tests/support/managed_http.rs @@ -59,14 +59,28 @@ impl IntentApiFixture { } fn start(replies: Vec, session: Option, delay: Duration) -> Self { + Self::start_with_origin(|_| replies, session, delay) + } + + /// Build replies after binding the exact origin, for origin-bound signed claims. + pub fn with_origin(replies: impl FnOnce(&str) -> Vec) -> Self { + Self::start_with_origin(replies, None, Duration::ZERO) + } + + fn start_with_origin( + replies: impl FnOnce(&str) -> Vec, + session: Option, + delay: Duration, + ) -> Self { use std::io::Write; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; - let reply_count = replies.len(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let origin = format!("http://{}", listener.local_addr().unwrap()); + let replies = replies(&origin); + let reply_count = replies.len(); let requests = Arc::new(Mutex::new(Vec::new())); let received = requests.clone(); let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index bc77b4c09..ad4c9fd71 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -103,6 +103,19 @@ fn snapshot_entity_count_at(graph: &std::path::Path, entity_kind: &str, type_nam .unwrap() } +fn served_policy_state(server: &TestServer) -> (Value, Value) { + let read = |arguments: &[&str]| { + parse_stdout_json(&output_success( + cli() + .env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok") + .args(["--server", &server.base_url, "--graph", "knowledge"]) + .args(arguments) + .arg("--json"), + )) + }; + (read(&["snapshot"]), read(&["branch", "list"])) +} + fn gemini_base_url() -> String { env::var("OMNIGRAPH_GEMINI_BASE_URL") .ok() @@ -1209,6 +1222,7 @@ fn local_cli_change_enforces_engine_layer_policy() { &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); let insert = "query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }"; + let before = served_policy_state(&server); // Case 1: no token → the server refuses before any policy check. let no_token = cli() @@ -1245,11 +1259,13 @@ fn local_cli_change_enforces_engine_layer_policy() { .output() .unwrap(); assert!(!denied.status.success(), "bruno/main must be denied"); - let denied_stderr = String::from_utf8_lossy(&denied.stderr); + let refusal = parse_stdout_json(&denied); + assert_eq!(refusal["code"], "forbidden"); assert!( - denied_stderr.contains("denied"), - "expected 'denied' message for bruno/main, got stderr: {denied_stderr}" + refusal["error"].as_str().unwrap().contains("denied"), + "expected a policy denial for bruno/main: {refusal}" ); + assert_eq!(served_policy_state(&server), before); // Case 3: ragnor token against main → permitted by admins-write. let allowed = parse_stdout_json(&output_success( @@ -1346,6 +1362,7 @@ fn local_cli_load_enforces_engine_layer_policy() { cluster.path(), &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); + let before = served_policy_state(&server); let temp = tempfile::tempdir().unwrap(); let data = temp.path().join("policy-load.jsonl"); // The seeded graph (test.jsonl) has Knows/WorksAt edges over its Persons, so a @@ -1381,11 +1398,13 @@ fn local_cli_load_enforces_engine_layer_policy() { .output() .unwrap(); assert!(!denied.status.success(), "bruno/main load must be denied"); - let stderr = String::from_utf8_lossy(&denied.stderr); + let refusal = parse_stdout_json(&denied); + assert_eq!(refusal["code"], "forbidden"); assert!( - stderr.contains("denied"), - "expected 'denied' for bruno/main load, got: {stderr}" + refusal["error"].as_str().unwrap().contains("denied"), + "expected a policy denial for bruno/main load: {refusal}" ); + assert_eq!(served_policy_state(&server), before); // act-ragnor: admins-write rule permits change anywhere. let allowed = parse_stdout_json(&output_success( @@ -1421,6 +1440,7 @@ fn local_cli_ingest_enforces_engine_layer_policy() { cluster.path(), &[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)], ); + let before = served_policy_state(&server); let temp = tempfile::tempdir().unwrap(); let data = temp.path().join("policy-ingest.jsonl"); fs::write( @@ -1444,11 +1464,13 @@ fn local_cli_ingest_enforces_engine_layer_policy() { .output() .unwrap(); assert!(!denied.status.success(), "bruno ingest must be denied"); - let stderr = String::from_utf8_lossy(&denied.stderr); + let refusal = parse_stdout_json(&denied); + assert_eq!(refusal["code"], "forbidden"); assert!( - stderr.contains("denied"), - "expected 'denied' for bruno ingest, got: {stderr}" + refusal["error"].as_str().unwrap().contains("denied"), + "expected a policy denial for bruno ingest: {refusal}" ); + assert_eq!(served_policy_state(&server), before); let allowed = parse_stdout_json(&output_success( cli() diff --git a/crates/omnigraph-cluster/src/authorization.rs b/crates/omnigraph-cluster/src/authorization.rs new file mode 100644 index 000000000..5e536e9e9 --- /dev/null +++ b/crates/omnigraph-cluster/src/authorization.rs @@ -0,0 +1,767 @@ +//! Configuration-owned authorization for callers that already authenticated +//! an identity. The applied ledger owns policy; candidate files never do. + +use std::io::Read; +use std::sync::Arc; + +use omnigraph_policy::{PolicyAction, PolicyEngine, PolicyRequest}; + +use super::*; + +const MAX_AUTHORIZATION_RESOURCES: usize = 4096; +const MAX_POLICY_BYTES: usize = 1_048_576; +const MAX_POLICY_TOTAL_BYTES: usize = 8_388_608; + +/// Identity supplied by a trusted authentication boundary, not request data. +/// Storage-holding embedders remain responsible for establishing that boundary. +#[derive(Debug, Clone)] +pub struct IdentityAuthorization { + actor: String, + bootstrap: Option, +} + +impl IdentityAuthorization { + pub fn authenticated(actor: impl Into) -> Result { + let actor = actor.into(); + if actor.is_empty() + || actor.len() > 256 + || actor.trim() != actor + || actor.chars().any(char::is_control) + { + return Err(refusal( + "identity_invalid", + "actor", + "authenticated actor is invalid", + )); + } + Ok(Self { + actor, + bootstrap: None, + }) + } + + /// Explicit first-initialization authority, already verified by the caller. + /// Never derive this capability from an absent policy or an empty graph list. + /// The caller must bind it to its exact initialization request and must not + /// issue it for normal apply, restore, or recovery. + pub fn bootstrap( + actor: impl Into, + initial_config_digest: String, + initial_resource_digests: BTreeMap, + ) -> Result { + let mut identity = Self::authenticated(actor)?; + if !valid_digest(&initial_config_digest) + || initial_resource_digests.len() > MAX_AUTHORIZATION_RESOURCES + || initial_resource_digests + .iter() + .any(|(address, digest)| address.len() > 512 || !valid_digest(digest)) + { + return Err(refusal( + "bootstrap_authority_invalid", + "bootstrap", + "initial configuration authority is invalid", + )); + } + identity.bootstrap = Some(BootstrapAuthorization { + initial_config_digest, + initial_resource_digests, + }); + Ok(identity) + } + + /// Derive an explicitly authorized initialization from local source files. + /// Like [`Self::bootstrap`], the caller must already hold exact trusted + /// bootstrap authority. This constructor performs no remote state or graph + /// reads; the authorized operation checks the pristine base before preview. + pub fn bootstrap_config_dir( + actor: impl Into, + config_dir: impl AsRef, + ) -> Result { + let outcome = load_desired(config_dir.as_ref()); + if let Some(diagnostic) = outcome + .diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(diagnostic); + } + let desired = outcome.desired.ok_or_else(|| { + refusal( + "configuration_invalid", + CLUSTER_CONFIG_FILE, + "initial configuration is unavailable", + ) + })?; + Self::bootstrap(actor, desired.config_digest, desired.resource_digests) + } + + pub fn actor(&self) -> &str { + &self.actor + } +} + +#[derive(Debug, Clone)] +struct BootstrapAuthorization { + initial_config_digest: String, + initial_resource_digests: BTreeMap, +} + +/// Exact candidate effect identity. Execution dispositions and migration +/// previews are derived; this binds every resource operation and both digests. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AuthorizedEffect { + pub resource: String, + pub operation: String, + pub before_digest: Option, + pub after_digest: Option, + pub binding_change: bool, + pub metadata_change: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PolicyAuthorizationCheck { + pub resource: String, + pub action: String, + pub branch: Option, + pub target_branch: Option, +} + +/// Evidence of policy evaluation, not a bearer capability. Apply rechecks the +/// actor and current applied policy under its lock before any effect. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PlanAuthorization { + pub version: u8, + pub actor: String, + pub canonical_root: String, + pub state_revision: u64, + pub state_cas: Option, + pub applied_config_digest: Option, + pub desired_config_digest: String, + pub policy_digests: BTreeMap, + pub effects: Vec, + pub checks: Vec, + pub bootstrap: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthorizedPlanOutput { + pub plan: PlanOutput, + pub authorization: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthorizedApplyOutput { + pub apply: ApplyOutput, + /// `None` guarantees this call stopped before recovery, graph or catalog + /// effects (it may have acquired/released the cluster lock). `Some` records + /// completed preflight; later failure may have effects and needs the normal + /// recovery analysis. This says nothing about effects from earlier calls. + pub authorization: Option, +} + +/// Current applied-policy evidence for a protected result projection. +#[derive(Debug, Clone, Serialize)] +pub struct PlanReadAuthorization { + pub actor: String, + pub canonical_root: String, + pub state_revision: u64, + pub state_cas: String, + pub applied_config_digest: Option, + pub policy_digests: BTreeMap, + pub graphs: Vec, +} + +pub(crate) struct AppliedPolicies { + cluster: PolicyEngine, + graphs: BTreeMap>, + digests: BTreeMap, +} + +impl AppliedPolicies { + pub(crate) async fn load( + backend: &ClusterStore, + state: &ClusterState, + ) -> Result { + if state.applied_revision.resources.len() > MAX_AUTHORIZATION_RESOURCES { + return Err(refusal( + "policy_bounds_exceeded", + CLUSTER_STATE_FILE, + "applied resource count exceeds the authorization bound", + )); + } + let mut cluster = None; + let mut graphs = BTreeMap::new(); + let mut digests = BTreeMap::new(); + let mut total_bytes = 0usize; + for (address, entry) in &state.applied_revision.resources { + let kind = resource_kind(address); + if !matches!(kind, ResourceKind::Policy(_)) { + continue; + } + let bindings = entry.applies_to.as_ref().ok_or_else(|| { + refusal( + "applied_policy_invalid", + address, + "applied policy has no scope bindings", + ) + })?; + let source = backend + .read_verified_payload_bounded( + &kind, + &entry.digest, + address, + MAX_POLICY_BYTES.min(MAX_POLICY_TOTAL_BYTES.saturating_sub(total_bytes)), + ) + .await?; + total_bytes = total_bytes.saturating_add(source.len()); + if source.len() > MAX_POLICY_BYTES || total_bytes > MAX_POLICY_TOTAL_BYTES { + return Err(refusal( + "policy_bounds_exceeded", + address, + "applied policy bytes exceed the authorization bound", + )); + } + for binding in bindings { + if binding == "cluster" { + let engine = + PolicyEngine::load_cluster_from_source(&source).map_err(|err| { + refusal("applied_policy_invalid", address, err.to_string()) + })?; + if cluster.replace(engine).is_some() { + return Err(refusal( + "applied_policy_invalid", + address, + "more than one policy binds cluster configuration", + )); + } + } else if let Some(graph) = binding.strip_prefix("graph.") { + let engine = + PolicyEngine::load_graph_from_source(&source, graph).map_err(|err| { + refusal("applied_policy_invalid", address, err.to_string()) + })?; + if graphs.insert(graph.to_string(), Arc::new(engine)).is_some() { + return Err(refusal( + "applied_policy_invalid", + address, + "more than one policy binds the graph", + )); + } + } else { + return Err(refusal( + "applied_policy_invalid", + address, + "unrecognized applied policy binding", + )); + } + } + digests.insert(address.clone(), entry.digest.clone()); + } + Ok(Self { + cluster: cluster.ok_or_else(|| refusal("cluster_policy_required", "cluster", "identity-authorized operations require an applied cluster management policy; migrate existing clusters explicitly"))?, + graphs, + digests, + }) + } + + fn check_cluster(&self, actor: &str) -> Result<(), Diagnostic> { + check( + &self.cluster, + actor, + PolicyAction::ConfigManage, + "cluster", + None, + None, + ) + } + + fn check_graph( + &self, + actor: &str, + graph: &str, + action: PolicyAction, + ) -> Result<(), Diagnostic> { + let policy = self.graphs.get(graph).ok_or_else(|| { + refusal( + "graph_policy_required", + graph, + "protected graph operation requires an applied graph policy", + ) + })?; + check( + policy, + actor, + action, + graph, + (action == PolicyAction::Read).then_some("main"), + (action == PolicyAction::SchemaApply).then_some("main"), + ) + } + + pub(crate) fn graph(&self, graph: &str) -> Option> { + self.graphs.get(graph).cloned() + } +} + +fn check( + policy: &PolicyEngine, + actor: &str, + action: PolicyAction, + resource: &str, + branch: Option<&str>, + target: Option<&str>, +) -> Result<(), Diagnostic> { + let decision = policy + .authorize( + actor, + &PolicyRequest { + action, + branch: branch.map(str::to_string), + target_branch: target.map(str::to_string), + }, + ) + .map_err(|err| refusal("policy_evaluation_failed", resource, err.to_string()))?; + if !decision.allowed { + return Err(refusal("policy_denied", resource, decision.message)); + } + Ok(()) +} + +pub(crate) async fn authorize_candidate( + backend: &ClusterStore, + desired: &DesiredCluster, + state: Option<&ClusterState>, + observations: &StateObservations, + changes: &[PlanChange], + identity: &IdentityAuthorization, + applying: bool, +) -> Result<(PlanAuthorization, Option), Diagnostic> { + if !desired.state_lock { + return Err(refusal( + "authorization_requires_lock", + "state.lock", + "identity-authorized configuration requires the cluster state lock", + )); + } + if changes.len() > MAX_AUTHORIZATION_RESOURCES { + return Err(refusal( + "policy_bounds_exceeded", + "changes", + "plan effect count exceeds the authorization bound", + )); + } + refuse_pending_recovery(backend).await?; + let bootstrap = identity.bootstrap.as_ref(); + let mut checks = Vec::new(); + let policies = if let Some(bootstrap) = bootstrap { + if bootstrap.initial_config_digest != desired.config_digest + || bootstrap.initial_resource_digests != desired.resource_digests + { + return Err(refusal( + "bootstrap_authority_mismatch", + "bootstrap", + "initial configuration differs from the explicitly authorized initialization", + )); + } + if state.is_some_and(|state| { + state.state_revision > 1 + || !state.applied_revision.resources.is_empty() + || !state.approval_records.is_empty() + || !state.recovery_records.is_empty() + || state.applied_revision.config_digest.as_deref() != Some(&desired.config_digest) + }) { + return Err(refusal( + "bootstrap_already_initialized", + "bootstrap", + "bootstrap authority cannot operate on an initialized cluster", + )); + } + // The explicit capability authorizes installation, not candidate-policy + // self-authorization. Validate that it actually installs an initial + // management policy for the authenticated creator. + validate_initial_policy(desired, &identity.actor)?; + None + } else { + let state = state.ok_or_else(|| { + refusal( + "cluster_policy_required", + CLUSTER_STATE_FILE, + "no applied policy exists; initialization requires explicit bootstrap authority", + ) + })?; + let policies = AppliedPolicies::load(backend, state).await?; + let mut graph_checks = BTreeSet::new(); + // Resource effects omit source bindings and top-level configuration + // metadata. Prove those parsed semantics stayed unchanged by replaying + // the canonical digest with the accepted resource digests. A schema + // content edit alone can then retain its narrower graph permission. + let original_resources = state_resource_digests(state); + let original_config = + desired_config_digest_from_semantics(&desired.config_semantics, &original_resources); + let mut needs_config = + state.applied_revision.config_digest.as_deref() != Some(&original_config); + for change in changes { + match resource_kind(&change.resource) { + ResourceKind::Schema(graph) + if state + .applied_revision + .resources + .contains_key(&graph_address(&graph)) => + { + graph_checks.insert(graph); + } + ResourceKind::Graph(graph) + if change.operation == PlanOperation::Update + && change.metadata_change.is_none() => + { + // Composite digest follows its independently checked schema + // and query effects; it is not separate authority. + let metadata_unchanged = state + .applied_revision + .resources + .get(&change.resource) + .zip( + desired + .graphs + .iter() + .find(|candidate| candidate.id == graph), + ) + .is_some_and(|(applied, candidate)| { + applied.embedding_provider == candidate.embedding_provider + && applied.external_blob_policy.clone().unwrap_or_default() + == candidate.external_blob_policy + }); + let has_resource_effect = changes.iter().any(|other| { + other.resource == schema_address(&graph) + || matches!( + resource_kind(&other.resource), + ResourceKind::Query { graph: ref query_graph, .. } + if query_graph == &graph + ) + }); + if !metadata_unchanged || !has_resource_effect { + needs_config = true; + } + } + _ => needs_config = true, + } + } + if needs_config { + policies.check_cluster(&identity.actor)?; + checks.push(PolicyAuthorizationCheck { + resource: "cluster".to_string(), + action: "config_manage".to_string(), + branch: None, + target_branch: None, + }); + } + for graph in graph_checks { + policies.check_graph(&identity.actor, &graph, PolicyAction::Read)?; + checks.push(PolicyAuthorizationCheck { + resource: graph_address(&graph), + action: "read".to_string(), + branch: Some("main".to_string()), + target_branch: None, + }); + if applying { + policies.check_graph(&identity.actor, &graph, PolicyAction::SchemaApply)?; + checks.push(PolicyAuthorizationCheck { + resource: graph_address(&graph), + action: "schema_apply".to_string(), + branch: None, + target_branch: Some("main".to_string()), + }); + } + } + // A read-write graph open also owns its recovery sweep. This caller + // was authorized for the candidate effects, not an interrupted + // writer's older data/schema/branch effects. Inspect every affected + // existing graph before any member of the candidate can begin. The + // apply invocation repeats this under its cluster lock; callers still + // retain the existing external graph-writer exclusion contract. + let affected_graphs = changes + .iter() + .filter_map(|change| match resource_kind(&change.resource) { + ResourceKind::Graph(graph) | ResourceKind::Schema(graph) => Some(graph), + ResourceKind::Query { graph, .. } => Some(graph), + _ => None, + }) + .filter(|graph| { + state + .applied_revision + .resources + .contains_key(&graph_address(graph)) + }) + .collect::>(); + for graph in affected_graphs { + Omnigraph::ensure_no_pending_recovery(&backend.graph_root(&graph)) + .await + .map_err(|error| { + refusal( + "policy_recovery_required", + graph_address(&graph), + error.to_string(), + ) + })?; + } + Some(policies) + }; + let evidence = PlanAuthorization { + version: 1, + actor: identity.actor.clone(), + canonical_root: backend.canonical_root()?, + state_revision: observations.state_revision, + state_cas: observations.state_cas.clone(), + applied_config_digest: observations.applied_config_digest.clone(), + desired_config_digest: desired.config_digest.clone(), + policy_digests: policies + .as_ref() + .map(|p| p.digests.clone()) + .unwrap_or_default(), + effects: changes.iter().map(effect).collect(), + checks, + bootstrap: bootstrap.is_some(), + }; + Ok((evidence, policies)) +} + +fn validate_initial_policy(desired: &DesiredCluster, actor: &str) -> Result<(), Diagnostic> { + let (address, _) = desired + .policy_bindings + .iter() + .find(|(_, bindings)| bindings.iter().any(|scope| scope == "cluster")) + .ok_or_else(|| { + refusal( + "bootstrap_policy_required", + "cluster", + "initialization must explicitly declare a cluster management policy", + ) + })?; + let resource = desired + .resources + .iter() + .find(|resource| &resource.address == address) + .ok_or_else(|| { + refusal( + "bootstrap_policy_required", + address, + "initial policy source missing", + ) + })?; + let file = fs::File::open(resource.path.as_ref().ok_or_else(|| { + refusal( + "bootstrap_policy_required", + address, + "initial policy source missing", + ) + })?) + .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; + let mut source = String::new(); + file.take(MAX_POLICY_BYTES as u64 + 1) + .read_to_string(&mut source) + .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; + if source.len() > MAX_POLICY_BYTES || sha256_hex(source.as_bytes()) != resource.digest { + return Err(refusal( + "resource_content_changed", + address, + "initial policy source differs from its authorized digest", + )); + } + let policy = PolicyEngine::load_cluster_from_source(&source) + .map_err(|err| refusal("bootstrap_policy_required", address, err.to_string()))?; + check( + &policy, + actor, + PolicyAction::ConfigManage, + "cluster", + None, + None, + ) +} + +pub(crate) fn compare_authorization( + expected: &PlanAuthorization, + actual: &PlanAuthorization, +) -> Result<(), Diagnostic> { + let same_base = expected.state_revision == actual.state_revision + && expected.state_cas == actual.state_cas + && expected.applied_config_digest == actual.applied_config_digest; + // The existing explicit import initializes only the empty ledger between + // an absent-root bootstrap plan and its apply. No normal path gets this. + let bootstrap_import = expected.bootstrap + && actual.bootstrap + && expected.state_revision == 0 + && expected.state_cas.is_none() + && actual.state_revision == 1 + && actual.applied_config_digest.as_ref() == Some(&actual.desired_config_digest); + if expected.version != 1 + || expected.effects.len() > MAX_AUTHORIZATION_RESOURCES + || expected.canonical_root != actual.canonical_root + || expected.desired_config_digest != actual.desired_config_digest + || expected.policy_digests != actual.policy_digests + || expected.effects != actual.effects + || expected.bootstrap != actual.bootstrap + || !(same_base || bootstrap_import) + { + return Err(refusal( + "plan_authorization_stale", + "authorization", + "exact plan, actor, root or applied policy revision changed; obtain a fresh authorized plan", + )); + } + Ok(()) +} + +fn effect(change: &PlanChange) -> AuthorizedEffect { + AuthorizedEffect { + resource: change.resource.clone(), + operation: match change.operation { + PlanOperation::Create => "create", + PlanOperation::Update => "update", + PlanOperation::Delete => "delete", + } + .to_string(), + before_digest: change.before_digest.clone(), + after_digest: change.after_digest.clone(), + binding_change: change.binding_change, + metadata_change: change.metadata_change.map(|metadata| { + match metadata { + PlanMetadataChange::PolicyBindings => "policy_bindings", + PlanMetadataChange::EmbeddingProfile => "embedding_profile", + } + .to_string() + }), + } +} + +pub(crate) async fn refuse_pending_recovery(backend: &ClusterStore) -> Result<(), Diagnostic> { + let mut diagnostics = Vec::new(); + let pending = backend + .list_recovery_sidecar_locations(&mut diagnostics) + .await; + if !pending.is_empty() || !diagnostics.is_empty() { + return Err(refusal( + "policy_recovery_required", + CLUSTER_RECOVERIES_DIR, + "pending or uncertain recovery must be resolved under its original authority before a new identity-authorized plan", + )); + } + Ok(()) +} + +/// Reauthorize a protected historical plan/result projection using CURRENT +/// applied policy. `graphs` must enumerate every remote schema represented by +/// that projection; the saved author's identity or receipt is never a read grant. +/// The caller must not release the projection when this function refuses. +pub async fn authorize_plan_read( + storage_root: &str, + identity: &IdentityAuthorization, + graphs: &[String], +) -> Result { + if graphs.len() > MAX_AUTHORIZATION_RESOURCES || identity.bootstrap.is_some() { + return Err(refusal( + "plan_read_authority_invalid", + "graphs", + "historical projection requires ordinary identity and bounded graph scope", + )); + } + let backend = ClusterStore::for_storage_root(storage_root)?; + let mut observations = backend.observations(); + let snapshot = backend.read_state(&mut observations).await?; + let state = snapshot.state.ok_or_else(|| { + refusal( + "cluster_policy_required", + CLUSTER_STATE_FILE, + "applied cluster state is required", + ) + })?; + refuse_pending_recovery(&backend).await?; + let policies = AppliedPolicies::load(&backend, &state).await?; + policies.check_cluster(&identity.actor)?; + let graphs: BTreeSet = graphs.iter().cloned().collect(); + for graph in &graphs { + policies.check_graph(&identity.actor, graph, PolicyAction::Read)?; + } + Ok(PlanReadAuthorization { + actor: identity.actor.clone(), + canonical_root: backend.canonical_root()?, + state_revision: observations.state_revision, + state_cas: observations.state_cas.ok_or_else(|| { + refusal( + "policy_revision_missing", + CLUSTER_STATE_FILE, + "applied state CAS is required", + ) + })?, + applied_config_digest: observations.applied_config_digest, + policy_digests: policies.digests, + graphs: graphs.into_iter().collect(), + }) +} + +/// Effect-free execution preflight for a trusted orchestrator. This checks the +/// complete candidate against current applied policy before the caller writes +/// its own execution artifacts. It does not acquire writer authority: the +/// authorized apply entry point repeats the check under the cluster lock. +pub async fn authorize_apply_plan( + config_dir: impl AsRef, + identity: &IdentityAuthorization, + expected: &PlanAuthorization, +) -> Result { + let outcome = load_desired(config_dir.as_ref()); + if let Some(diagnostic) = outcome + .diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(diagnostic); + } + let desired = outcome.desired.ok_or_else(|| { + refusal( + "configuration_invalid", + CLUSTER_CONFIG_FILE, + "candidate configuration is unavailable", + ) + })?; + let backend = store_for(&desired.config_dir, desired.storage_root.as_deref())?; + let mut observations = backend.observations(); + let snapshot = backend.read_state(&mut observations).await?; + if let Some(state) = &snapshot.state { + let mut diagnostics = Vec::new(); + if !validate_state_graph_resource_digests(state, &mut diagnostics) { + return Err(diagnostics.remove(0)); + } + } + let prior = snapshot + .state + .as_ref() + .map(state_resource_digests) + .unwrap_or_default(); + let mut changes = diff_resources(&prior, &desired.resource_digests); + append_policy_binding_changes(&mut changes, snapshot.state.as_ref(), &desired); + append_embedding_profile_changes(&mut changes, snapshot.state.as_ref(), &desired); + let (authorization, _) = authorize_candidate( + &backend, + &desired, + snapshot.state.as_ref(), + &observations, + &changes, + identity, + true, + ) + .await?; + compare_authorization(expected, &authorization)?; + Ok(authorization) +} + +fn valid_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} +fn refusal(code: &str, path: impl Into, message: impl Into) -> Diagnostic { + Diagnostic::error(code, path, message) +} diff --git a/crates/omnigraph-cluster/src/config.rs b/crates/omnigraph-cluster/src/config.rs index 063df707f..ec4742bd6 100644 --- a/crates/omnigraph-cluster/src/config.rs +++ b/crates/omnigraph-cluster/src/config.rs @@ -960,7 +960,7 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { omnigraph_policy::PolicyConfig::from_source(&source).and_then(|_| { match (binds_cluster, graph_binding.as_deref()) { (true, None) => { - omnigraph_policy::PolicyEngine::load_server_from_source(&source) + omnigraph_policy::PolicyEngine::load_cluster_from_source(&source) .map(|_| ()) } (false, Some(graph_id)) => { @@ -1024,6 +1024,8 @@ pub(crate) fn load_desired(config_dir: &Path) -> LoadOutcome { desired: Some(DesiredCluster { config_dir: config_dir.clone(), config_digest, + config_semantics: serde_json::to_string(&raw) + .expect("raw cluster config must serialize deterministically"), storage_root: settings.storage_root.clone(), state_lock: settings.state_lock, graphs, diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 6528349e1..abe83b8a7 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -24,6 +24,7 @@ use ulid::Ulid; pub mod seams; +mod authorization; mod config; mod diff; mod serve; @@ -31,6 +32,11 @@ mod state_lock; mod store; mod sweep; mod types; +pub use authorization::{ + AuthorizedApplyOutput, AuthorizedEffect, AuthorizedPlanOutput, IdentityAuthorization, + PlanAuthorization, PlanReadAuthorization, PolicyAuthorizationCheck, authorize_apply_plan, + authorize_plan_read, +}; use config::{ QueriesDecl, graph_address, initial_import_state, load_desired, observe_declared_graphs, parse_cluster_config, preview_schema_migration, schema_address, state_resource_digests, @@ -137,13 +143,50 @@ pub async fn plan_config_dir(config_dir: impl AsRef) -> PlanOutput { pub async fn plan_config_dir_with_options( config_dir: impl AsRef, options: PlanOptions, +) -> PlanOutput { + // Keep the shared implementation off the forwarding caller's stack. + Box::pin(plan_config_dir_impl( + config_dir.as_ref(), + options, + None, + &mut None, + )) + .await +} + +/// Plan using the current applied policy for an already authenticated actor. +/// Existing storage-holder entry points retain their explicit trust boundary. +pub async fn plan_config_dir_authorized( + config_dir: impl AsRef, + options: PlanOptions, + identity: &IdentityAuthorization, +) -> AuthorizedPlanOutput { + let mut authorization = None; + let plan = Box::pin(plan_config_dir_impl( + config_dir.as_ref(), + options, + Some(identity), + &mut authorization, + )) + .await; + AuthorizedPlanOutput { + plan, + authorization, + } +} + +async fn plan_config_dir_impl( + config_dir: &Path, + options: PlanOptions, + identity: Option<&IdentityAuthorization>, + authorization: &mut Option, ) -> PlanOutput { let mut authority = if options.observe { LedgerAuthority::Observed } else { LedgerAuthority::Locked }; - let outcome = load_desired(config_dir.as_ref()); + let outcome = load_desired(config_dir); let mut diagnostics = outcome.diagnostics; let storage_root = outcome .desired @@ -262,6 +305,28 @@ pub async fn plan_config_dir_with_options( &approved, ); + if !has_errors(&diagnostics) { + if let Some(identity) = identity { + match authorization::authorize_candidate( + &backend, + &desired, + prior_state.as_ref(), + &observations, + &changes, + identity, + false, + ) + .await + { + Ok((evidence, _)) => *authorization = Some(evidence), + Err(diagnostic) => { + diagnostics.push(diagnostic); + changes.clear(); + } + } + } + } + // Embed real migration steps for schema updates so plan is a data-aware // preview; failures degrade to the digest diff with a warning. for change in &mut changes { @@ -370,7 +435,51 @@ pub async fn apply_config_dir_with_options( config_dir: impl AsRef, options: ApplyOptions, ) -> ApplyOutput { - let outcome = load_desired(config_dir.as_ref()); + // Preserve the existing embedded caller's stack budget when forwarding + // into the shared implementation and nested graph recovery operations. + Box::pin(apply_config_dir_impl( + config_dir.as_ref(), + options, + None, + &mut None, + )) + .await +} + +/// Apply the exact authorized candidate after rechecking current applied policy +/// for the initiating identity under the existing cluster lock. The receipt is +/// a base/effect precondition, not transferable authorization. +pub async fn apply_config_dir_authorized( + config_dir: impl AsRef, + options: ApplyOptions, + identity: &IdentityAuthorization, + expected: &PlanAuthorization, +) -> AuthorizedApplyOutput { + let mut authorization = None; + let apply = Box::pin(apply_config_dir_impl( + config_dir.as_ref(), + options, + Some((identity, expected)), + &mut authorization, + )) + .await; + AuthorizedApplyOutput { + apply, + authorization, + } +} + +async fn apply_config_dir_impl( + config_dir: &Path, + mut options: ApplyOptions, + identity: Option<(&IdentityAuthorization, &PlanAuthorization)>, + authorization: &mut Option, +) -> ApplyOutput { + if let Some((identity, _)) = identity { + // Attribution comes from authenticated identity on this entry point. + options.actor = Some(identity.actor().to_string()); + } + let outcome = load_desired(config_dir); let mut diagnostics = outcome.diagnostics; let storage_root = outcome .desired @@ -504,6 +613,49 @@ pub async fn apply_config_dir_with_options( ); } + // Authenticate every exact candidate effect against the as-read applied + // revision BEFORE recovery cleanup, sidecars, graph opens or payload writes. + // Pending recovery refuses; a new caller cannot inherit its original actor. + let mut applied_policies = None; + if let Some((identity, expected)) = identity { + let mut candidate_changes = + diff_resources(&state_resource_digests(&state), &desired.resource_digests); + append_policy_binding_changes(&mut candidate_changes, Some(&state), &desired); + append_embedding_profile_changes(&mut candidate_changes, Some(&state), &desired); + match authorization::authorize_candidate( + &backend, + &desired, + Some(&state), + &observations, + &candidate_changes, + identity, + true, + ) + .await + { + Ok((evidence, policies)) => { + match authorization::compare_authorization(expected, &evidence) { + Ok(()) => { + *authorization = Some(evidence); + applied_policies = policies; + } + Err(diagnostic) => diagnostics.push(diagnostic), + } + } + Err(diagnostic) => diagnostics.push(diagnostic), + } + if has_errors(&diagnostics) { + return early_return( + display_path(&desired.config_dir), + Some(desired.config_digest), + observations, + Vec::new(), + state.resource_statuses, + diagnostics, + ); + } + } + // Snapshot the as-read state BEFORE the sweep so sweep mutations count as // changes for the final dirty check and get persisted by the state CAS. let before_value = @@ -748,6 +900,13 @@ pub async fn apply_config_dir_with_options( continue; } }; + let db = match applied_policies + .as_ref() + .and_then(|policies| policies.graph(graph_id)) + { + Some(policy) => db.with_policy(policy), + None => db, + }; // Re-read + digest-verify the desired schema source before the // cluster sidecar exists. Parser/planner rejections cannot have // moved graph state, so they must not leave recovery work behind. @@ -2378,12 +2537,19 @@ fn desired_config_digest( raw: &RawClusterConfig, resource_digests: &BTreeMap, ) -> String { - let mut input = String::from("cluster-config\0"); // Hash parsed semantics, not raw YAML bytes, so comments and formatting do // not create a new desired revision and the digest cannot drift from parse. let config_semantics = serde_json::to_string(raw).expect("raw cluster config must serialize deterministically"); - input.push_str(&config_semantics); + desired_config_digest_from_semantics(&config_semantics, resource_digests) +} + +fn desired_config_digest_from_semantics( + config_semantics: &str, + resource_digests: &BTreeMap, +) -> String { + let mut input = String::from("cluster-config\0"); + input.push_str(config_semantics); input.push('\0'); for (address, digest) in resource_digests { input.push_str(address); diff --git a/crates/omnigraph-cluster/src/store.rs b/crates/omnigraph-cluster/src/store.rs index e015be64d..ac5173d56 100644 --- a/crates/omnigraph-cluster/src/store.rs +++ b/crates/omnigraph-cluster/src/store.rs @@ -471,6 +471,28 @@ impl ClusterStore { kind: &ResourceKind, digest: &str, address: &str, + ) -> Result { + self.read_verified_payload_with_limit(kind, digest, address, None) + .await + } + + pub(crate) async fn read_verified_payload_bounded( + &self, + kind: &ResourceKind, + digest: &str, + address: &str, + max_bytes: usize, + ) -> Result { + self.read_verified_payload_with_limit(kind, digest, address, Some(max_bytes)) + .await + } + + async fn read_verified_payload_with_limit( + &self, + kind: &ResourceKind, + digest: &str, + address: &str, + max_bytes: Option, ) -> Result { let Some(relative) = Self::payload_relative(kind, digest) else { return Err(Diagnostic::error( @@ -480,7 +502,10 @@ impl ClusterStore { )); }; let uri = self.uri(&relative); - let text = self.adapter.read_text(&uri).await.map_err(|err| { + let text = match max_bytes { + Some(max_bytes) => self.adapter.read_text_if_exists_bounded(&uri, max_bytes as u64).await, + None => self.adapter.read_text(&uri).await.map(Some), + }.map_err(|err| { Diagnostic::error( "catalog_payload_missing", address, @@ -489,7 +514,7 @@ impl ClusterStore { self.display(&relative) ), ) - })?; + })?.ok_or_else(|| Diagnostic::error("catalog_payload_missing", address, "applied catalog payload is absent"))?; if sha256_hex(text.as_bytes()) != digest { return Err(Diagnostic::error( "catalog_payload_digest_mismatch", diff --git a/crates/omnigraph-cluster/src/tests.rs b/crates/omnigraph-cluster/src/tests.rs index 7e40af183..ab262812b 100644 --- a/crates/omnigraph-cluster/src/tests.rs +++ b/crates/omnigraph-cluster/src/tests.rs @@ -28,6 +28,623 @@ query find_person($name: String) { const POLICY: &str = "version: 1\nrules: []\n"; +const IDENTITY_GRAPH_POLICY: &str = r#" +version: 1 +groups: + owners: [principal:owner, principal:collaborator] + readers: [principal:reader] +rules: + - id: owners-read + allow: {actors: {group: owners}, actions: [read]} + - id: owners-schema + allow: {actors: {group: owners}, actions: [schema_apply], target_branch_scope: any} + - id: readers-read + allow: {actors: {group: readers}, actions: [read]} +"#; + +const IDENTITY_CLUSTER_POLICY: &str = r#" +version: 1 +groups: + owners: [principal:owner, principal:collaborator] +rules: + - id: configure-cluster + allow: {actors: {group: owners}, actions: [config_manage]} +"#; + +fn identity_fixture() -> tempfile::TempDir { + let dir = fixture(); + fs::write(dir.path().join("base.policy.yaml"), IDENTITY_GRAPH_POLICY).unwrap(); + fs::write( + dir.path().join("management.policy.yaml"), + IDENTITY_CLUSTER_POLICY, + ) + .unwrap(); + let config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + fs::write( + dir.path().join(CLUSTER_CONFIG_FILE), + format!( + "{config} management:\n file: ./management.policy.yaml\n applies_to: [cluster]\n" + ), + ) + .unwrap(); + dir +} + +async fn apply_identity_fixture(dir: &Path) { + let import = import_config_dir(dir).await; + assert!(import.ok, "{:?}", import.diagnostics); + let apply = apply_config_dir(dir).await; + assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics); +} + +async fn identity_manifest_version(dir: &Path) -> u64 { + let uri = dir.join("graphs/knowledge.omni"); + let db = Omnigraph::open_read_only(uri.to_str().unwrap()) + .await + .unwrap(); + db.snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version() +} + +fn expand_identity_schema(dir: &Path) { + fs::write( + dir.join("people.pg"), + SCHEMA.replace("age: I32?", "age: I32?\n email: String?"), + ) + .unwrap(); +} + +#[tokio::test] +async fn identity_apply_cannot_disable_the_state_lock() { + let dir = identity_fixture(); + apply_identity_fixture(dir.path()).await; + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let permitted = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(permitted.plan.ok, "{:?}", permitted.plan.diagnostics); + let expected = permitted.authorization.unwrap(); + let config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + assert!(config.contains("lock: true")); + fs::write( + dir.path().join(CLUSTER_CONFIG_FILE), + config.replace("lock: true", "lock: false"), + ) + .unwrap(); + let planned = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(!planned.plan.ok); + assert!(planned.authorization.is_none()); + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let denied = authorize_apply_plan(dir.path(), &owner, &expected) + .await + .unwrap_err(); + assert_eq!(denied.code, "authorization_requires_lock"); + let denied = + apply_config_dir_authorized(dir.path(), ApplyOptions::default(), &owner, &expected).await; + assert!(!denied.apply.ok); + assert!(denied.authorization.is_none()); + assert!( + denied + .apply + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "authorization_requires_lock") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger + ); +} + +#[tokio::test] +async fn identity_raw_configuration_changes_require_cluster_authority_without_resource_effects() { + let dir = identity_fixture(); + fs::write( + dir.path().join("management.policy.yaml"), + IDENTITY_CLUSTER_POLICY.replace( + "[principal:owner, principal:collaborator]", + "[principal:owner]", + ), + ) + .unwrap(); + apply_identity_fixture(dir.path()).await; + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let collaborator = IdentityAuthorization::authenticated("principal:collaborator").unwrap(); + let original = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + let ledger = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let manifest = identity_manifest_version(dir.path()).await; + fs::write(dir.path().join("renamed.pg"), SCHEMA).unwrap(); + + for (case, candidate, schema) in [ + ( + "metadata", + original.replace("name: test", "name: renamed"), + SCHEMA.to_string(), + ), + ( + "source binding", + original.replace("./people.pg", "./renamed.pg"), + SCHEMA.to_string(), + ), + ( + "metadata with schema", + original.replace("name: test", "name: renamed"), + SCHEMA.replace("age: I32?", "age: I32?\n email: String?"), + ), + ] { + fs::write(dir.path().join(CLUSTER_CONFIG_FILE), candidate).unwrap(); + fs::write(dir.path().join("people.pg"), schema).unwrap(); + let planned = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(planned.plan.ok, "{case}: {:?}", planned.plan.diagnostics); + let proof = planned.authorization.unwrap(); + assert!( + proof + .checks + .iter() + .any(|check| check.action == "config_manage"), + "{case}" + ); + if case != "metadata with schema" { + assert!(proof.effects.is_empty(), "{case}: {:?}", proof.effects); + } + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &collaborator) + .await; + assert!(!denied.plan.ok, "{case}"); + assert!(denied.authorization.is_none(), "{case}"); + let denied = + apply_config_dir_authorized(dir.path(), ApplyOptions::default(), &collaborator, &proof) + .await; + assert!(!denied.apply.ok, "{case}"); + assert!(denied.authorization.is_none(), "{case}"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + ledger, + "{case}" + ); + assert_eq!( + identity_manifest_version(dir.path()).await, + manifest, + "{case}" + ); + } +} + +#[tokio::test] +async fn identity_schema_authorization_uses_applied_policy_and_preserves_collaborative_apply() { + let dir = identity_fixture(); + apply_identity_fixture(dir.path()).await; + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let reader = IdentityAuthorization::authenticated("principal:reader").unwrap(); + let stranger = IdentityAuthorization::authenticated("principal:stranger").unwrap(); + let collaborator = IdentityAuthorization::authenticated("principal:collaborator").unwrap(); + expand_identity_schema(dir.path()); + let before_state = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let before_manifest = identity_manifest_version(dir.path()).await; + let denied_plan = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &stranger).await; + assert!(!denied_plan.plan.ok); + assert!( + denied_plan.plan.changes.is_empty(), + "no protected migration preview on denial" + ); + assert!(denied_plan.authorization.is_none()); + let plan = plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(plan.plan.ok, "{:?}", plan.plan.diagnostics); + assert!( + plan.plan + .changes + .iter() + .any(|change| change.migration.is_some()) + ); + let expected = plan.authorization.unwrap(); + assert!( + authorize_apply_plan(dir.path(), &reader, &expected) + .await + .is_err() + ); + let checked = authorize_apply_plan(dir.path(), &collaborator, &expected) + .await + .unwrap(); + assert_eq!(checked.actor, "principal:collaborator"); + assert!( + checked + .checks + .iter() + .any(|check| check.action == "schema_apply") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_state + ); + assert_eq!(identity_manifest_version(dir.path()).await, before_manifest); + let denied_apply = + apply_config_dir_authorized(dir.path(), ApplyOptions::default(), &reader, &expected).await; + assert!(!denied_apply.apply.ok); + assert!( + denied_apply + .apply + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy_denied") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_state + ); + assert_eq!(identity_manifest_version(dir.path()).await, before_manifest); + assert_eq!( + fs::read_dir(dir.path().join(CLUSTER_RECOVERIES_DIR)) + .unwrap() + .count(), + 0 + ); + let applied = apply_config_dir_authorized( + dir.path(), + ApplyOptions { + actor: Some("forged".to_string()), + }, + &collaborator, + &expected, + ) + .await; + assert!( + applied.apply.ok && applied.apply.converged, + "{:?}", + applied.apply.diagnostics + ); + assert_eq!( + applied.apply.actor.as_deref(), + Some("principal:collaborator") + ); + assert_eq!( + applied.authorization.unwrap().actor, + "principal:collaborator" + ); + assert!(identity_manifest_version(dir.path()).await > before_manifest); + assert!( + authorize_plan_read( + dir.path().to_str().unwrap(), + &stranger, + &["knowledge".to_string()] + ) + .await + .is_err() + ); + assert!( + authorize_plan_read( + dir.path().to_str().unwrap(), + &owner, + &["knowledge".to_string()] + ) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn identity_apply_preflights_all_effects_and_candidate_policy_cannot_self_authorize() { + let dir = identity_fixture(); + apply_identity_fixture(dir.path()).await; + let reader = IdentityAuthorization::authenticated("principal:reader").unwrap(); + let before_state = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let before_manifest = identity_manifest_version(dir.path()).await; + // A desired policy update grants the caller every permission, but it is + // not applied and therefore cannot authorize either the plan or its writes. + fs::write( + dir.path().join("management.policy.yaml"), + IDENTITY_CLUSTER_POLICY.replace("principal:owner", "principal:reader"), + ) + .unwrap(); + fs::write( + dir.path().join("base.policy.yaml"), + IDENTITY_GRAPH_POLICY.replace("principal:owner", "principal:reader"), + ) + .unwrap(); + expand_identity_schema(dir.path()); + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &reader).await; + assert!(!denied.plan.ok); + assert!(denied.authorization.is_none()); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_state + ); + assert_eq!(identity_manifest_version(dir.path()).await, before_manifest); + + // Altering the expected exact effect set cannot authorize a different plan. + fs::write(dir.path().join("base.policy.yaml"), IDENTITY_GRAPH_POLICY).unwrap(); + fs::write( + dir.path().join("management.policy.yaml"), + IDENTITY_CLUSTER_POLICY, + ) + .unwrap(); + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let plan = plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(plan.plan.ok, "{:?}", plan.plan.diagnostics); + let mut changed_plan = plan.authorization.unwrap(); + changed_plan.effects.clear(); + let denied = + apply_config_dir_authorized(dir.path(), ApplyOptions::default(), &owner, &changed_plan) + .await; + assert!(!denied.apply.ok); + assert!( + denied + .apply + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "plan_authorization_stale") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_state + ); + assert_eq!(identity_manifest_version(dir.path()).await, before_manifest); +} + +#[tokio::test] +async fn identity_apply_checks_denied_configuration_effect_before_allowed_schema_effect() { + let dir = identity_fixture(); + fs::write( + dir.path().join("management.policy.yaml"), + IDENTITY_CLUSTER_POLICY.replace(", principal:collaborator", ""), + ) + .unwrap(); + apply_identity_fixture(dir.path()).await; + expand_identity_schema(dir.path()); + fs::write( + dir.path().join("people.gq"), + QUERY.replace("$p.age", "$p.age, $p.email"), + ) + .unwrap(); + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let collaborator = IdentityAuthorization::authenticated("principal:collaborator").unwrap(); + let plan = plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(plan.plan.ok, "{:?}", plan.plan.diagnostics); + let before_state = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let before_manifest = identity_manifest_version(dir.path()).await; + let denied = apply_config_dir_authorized( + dir.path(), + ApplyOptions::default(), + &collaborator, + &plan.authorization.unwrap(), + ) + .await; + assert!(!denied.apply.ok); + assert!( + denied + .apply + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy_denied" && diagnostic.path == "cluster") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before_state + ); + assert_eq!(identity_manifest_version(dir.path()).await, before_manifest); + assert!(denied.authorization.is_none()); +} + +#[tokio::test] +async fn identity_policy_activation_invalidates_old_plan_and_tampering_fails_closed() { + let dir = identity_fixture(); + apply_identity_fixture(dir.path()).await; + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + expand_identity_schema(dir.path()); + let planned = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(planned.plan.ok, "{:?}", planned.plan.diagnostics); + let expected = planned.authorization.unwrap(); + // Migrate policy through the existing explicit storage-holder path, then + // retry the old candidate with the same authenticated identity. + fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); + fs::write( + dir.path().join("base.policy.yaml"), + IDENTITY_GRAPH_POLICY + .replace("[schema_apply]", "[read]") + .replace(", target_branch_scope: any", ""), + ) + .unwrap(); + let migrated = apply_config_dir(dir.path()).await; + assert!(migrated.ok, "{:?}", migrated.diagnostics); + expand_identity_schema(dir.path()); + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let manifest = identity_manifest_version(dir.path()).await; + let denied = + apply_config_dir_authorized(dir.path(), ApplyOptions::default(), &owner, &expected).await; + assert!(!denied.apply.ok); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert_eq!(identity_manifest_version(dir.path()).await, manifest); + let state = read_state_json(dir.path()); + let digest = state["applied_revision"]["resources"]["policy.base"]["digest"] + .as_str() + .unwrap(); + fs::write( + policy_payload_path(dir.path(), digest), + IDENTITY_GRAPH_POLICY, + ) + .unwrap(); + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(!denied.plan.ok); + assert!( + denied + .plan + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "catalog_payload_digest_mismatch") + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); +} + +#[tokio::test] +async fn identity_bootstrap_is_explicit_exact_once_and_recovery_does_not_reopen_it() { + let dir = identity_fixture(); + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(!denied.plan.ok); + let desired = load_desired(dir.path()).desired.unwrap(); + let bootstrap = IdentityAuthorization::bootstrap( + "principal:owner", + desired.config_digest, + desired.resource_digests, + ) + .unwrap(); + let initial = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &bootstrap).await; + assert!(initial.plan.ok, "{:?}", initial.plan.diagnostics); + let authorization = initial.authorization.unwrap(); + assert!( + authorize_apply_plan(dir.path(), &bootstrap, &authorization) + .await + .is_ok() + ); + assert!(!dir.path().join(CLUSTER_STATE_FILE).exists()); + let imported = import_config_dir(dir.path()).await; + assert!(imported.ok, "{:?}", imported.diagnostics); + let applied = apply_config_dir_authorized( + dir.path(), + ApplyOptions::default(), + &bootstrap, + &authorization, + ) + .await; + assert!( + applied.apply.ok && applied.apply.converged, + "{:?}", + applied.apply.diagnostics + ); + let state = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &bootstrap).await; + assert!(!denied.plan.ok); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + state + ); + fs::create_dir_all(dir.path().join(CLUSTER_RECOVERIES_DIR)).unwrap(); + let sidecar = dir.path().join(CLUSTER_RECOVERIES_DIR).join("pending.json"); + fs::write(&sidecar, "uncertain old authority").unwrap(); + let denied = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(!denied.plan.ok); + assert!( + denied + .plan + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "policy_recovery_required") + ); + assert_eq!( + fs::read_to_string(sidecar).unwrap(), + "uncertain old authority" + ); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + state + ); +} + +#[tokio::test] +async fn identity_new_graph_uses_existing_cluster_authority_and_empty_migration_is_explicit() { + let dir = identity_fixture(); + // Cluster configuration authority does not imply schema rights on an + // existing graph, but does authorize a newly declared graph's initial schema. + fs::write( + dir.path().join("base.policy.yaml"), + IDENTITY_GRAPH_POLICY.replace("principal:owner", "principal:graph-owner"), + ) + .unwrap(); + apply_identity_fixture(dir.path()).await; + let owner = IdentityAuthorization::authenticated("principal:owner").unwrap(); + let config = fs::read_to_string(dir.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + fs::write( + dir.path().join(CLUSTER_CONFIG_FILE), + config.replace("graphs:\n", "graphs:\n fresh:\n schema: ./people.pg\n"), + ) + .unwrap(); + let planned = + plan_config_dir_authorized(dir.path(), PlanOptions { observe: true }, &owner).await; + assert!(planned.plan.ok, "{:?}", planned.plan.diagnostics); + let applied = apply_config_dir_authorized( + dir.path(), + ApplyOptions::default(), + &owner, + &planned.authorization.unwrap(), + ) + .await; + assert!( + applied.apply.ok && applied.apply.converged, + "{:?}", + applied.apply.diagnostics + ); + assert!(dir.path().join("graphs/fresh.omni").exists()); + assert_eq!( + applied + .authorization + .unwrap() + .checks + .iter() + .map(|check| check.action.as_str()) + .collect::>(), + ["config_manage"] + ); + + let empty = identity_fixture(); + let candidate = fs::read_to_string(empty.path().join(CLUSTER_CONFIG_FILE)).unwrap(); + fs::write( + empty.path().join(CLUSTER_CONFIG_FILE), + "version: 1\ngraphs: {}\n", + ) + .unwrap(); + apply_identity_fixture(empty.path()).await; + let before = fs::read(empty.path().join(CLUSTER_STATE_FILE)).unwrap(); + fs::write(empty.path().join(CLUSTER_CONFIG_FILE), candidate).unwrap(); + let denied = + plan_config_dir_authorized(empty.path(), PlanOptions { observe: true }, &owner).await; + assert!(!denied.plan.ok); + assert!( + denied + .plan + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "cluster_policy_required") + ); + let desired = load_desired(empty.path()).desired.unwrap(); + let bootstrap = IdentityAuthorization::bootstrap( + "principal:owner", + desired.config_digest, + desired.resource_digests, + ) + .unwrap(); + let denied = + plan_config_dir_authorized(empty.path(), PlanOptions { observe: true }, &bootstrap).await; + assert!(!denied.plan.ok); + assert!( + denied + .plan + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "bootstrap_already_initialized") + ); + assert_eq!( + fs::read(empty.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert!(!empty.path().join(CLUSTER_GRAPHS_DIR).exists()); +} + fn fixture() -> tempfile::TempDir { let dir = tempdir().unwrap(); fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); diff --git a/crates/omnigraph-cluster/src/types.rs b/crates/omnigraph-cluster/src/types.rs index db0ebffac..c40266ce8 100644 --- a/crates/omnigraph-cluster/src/types.rs +++ b/crates/omnigraph-cluster/src/types.rs @@ -366,6 +366,8 @@ pub struct ApproveOutput { pub(crate) struct DesiredCluster { pub(crate) config_dir: PathBuf, pub(crate) config_digest: String, + /// Canonical parsed source semantics, excluding referenced resource bytes. + pub(crate) config_semantics: String, /// The declared `storage:` root, if any (None ⇒ the config dir itself). pub(crate) storage_root: Option, pub(crate) state_lock: bool, diff --git a/crates/omnigraph-cluster/tests/identity_recovery.rs b/crates/omnigraph-cluster/tests/identity_recovery.rs new file mode 100644 index 000000000..ac0510ee0 --- /dev/null +++ b/crates/omnigraph-cluster/tests/identity_recovery.rs @@ -0,0 +1,163 @@ +//! An identity-authorized schema change cannot inherit an older data write's +//! recovery effects. Fault injection lives in a separate integration process. + +#![cfg(feature = "failpoints")] + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use omnigraph::db::Omnigraph; +use omnigraph::seams::{FailScenario, catalog}; +use omnigraph_cluster::{ + ApplyOptions, IdentityAuthorization, PlanOptions, apply_config_dir, + apply_config_dir_authorized, authorize_apply_plan, import_config_dir, + plan_config_dir_authorized, +}; + +const SCHEMA: &str = "node Person { name: String @key }"; + +fn file_bytes(root: &Path) -> BTreeMap> { + fn collect(base: &Path, path: &Path, files: &mut BTreeMap>) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect(base, &path, files); + } else { + files.insert( + path.strip_prefix(base).unwrap().to_owned(), + fs::read(path).unwrap(), + ); + } + } + } + let mut files = BTreeMap::new(); + collect(root, root, &mut files); + files +} + +#[tokio::test] +async fn identity_schema_apply_refuses_real_pending_data_recovery_without_effects() { + let _scenario = FailScenario::setup(); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("people.pg"), SCHEMA).unwrap(); + fs::write( + dir.path().join("graph.policy.yaml"), + "version: 1\ngroups:\n schema: [principal:schema]\nrules:\n - id: schema-read\n allow: { actors: { group: schema }, actions: [read] }\n - id: schema-apply\n allow: { actors: { group: schema }, actions: [schema_apply], target_branch_scope: any }\n", + ) + .unwrap(); + fs::write( + dir.path().join("cluster.policy.yaml"), + "version: 1\ngroups:\n owners: [principal:operator]\nrules:\n - id: config\n allow: { actors: { group: owners }, actions: [config_manage] }\n", + ) + .unwrap(); + fs::write( + dir.path().join("cluster.yaml"), + "version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [knowledge]\n management:\n file: ./cluster.policy.yaml\n applies_to: [cluster]\n", + ) + .unwrap(); + let imported = Box::pin(import_config_dir(dir.path())).await; + assert!(imported.ok, "{:?}", imported.diagnostics); + let applied = Box::pin(apply_config_dir(dir.path())).await; + assert!(applied.ok && applied.converged, "{:?}", applied.diagnostics); + + fs::write( + dir.path().join("people.pg"), + "node Person { name: String @key\n email: String? }", + ) + .unwrap(); + let caller = IdentityAuthorization::authenticated("principal:schema").unwrap(); + let planned = Box::pin(plan_config_dir_authorized( + dir.path(), + PlanOptions { observe: true }, + &caller, + )) + .await; + assert!(planned.plan.ok, "{:?}", planned.plan.diagnostics); + let expected = planned.authorization.unwrap(); + + // This is a real effects-confirmed Mutation sidecar, not hand-written JSON. + // The table transaction has committed while the graph manifest is unchanged. + let graph = dir.path().join("graphs/knowledge.omni"); + let uri = graph.to_str().unwrap(); + let writer = Box::pin(Omnigraph::open(uri)).await.unwrap(); + { + let _failpoint = catalog::MUTATION_POST_FINALIZE_PRE_PUBLISHER.fire_always(); + let error = Box::pin(writer.mutate_as( + "main", + "query add() { insert Person { name: \"interrupted\" } }", + "add", + &Default::default(), + Some("principal:writer"), + )) + .await + .unwrap_err(); + assert!(error.to_string().contains("injected failpoint"), "{error}"); + } + drop(writer); + assert_eq!(fs::read_dir(graph.join("__recovery")).unwrap().count(), 1); + let before_graph = file_bytes(&graph); + let ledger = dir.path().join("__cluster/state.json"); + let before_ledger = fs::read(&ledger).unwrap(); + + let preview = Box::pin(plan_config_dir_authorized( + dir.path(), + PlanOptions { observe: true }, + &caller, + )) + .await; + assert!( + !preview.plan.ok, + "pending data recovery must block a new plan" + ); + assert!(preview.authorization.is_none()); + assert!( + Box::pin(authorize_apply_plan(dir.path(), &caller, &expected)) + .await + .is_err() + ); + let refused = Box::pin(apply_config_dir_authorized( + dir.path(), + ApplyOptions::default(), + &caller, + &expected, + )) + .await; + assert!( + !refused.apply.ok, + "pending data recovery must block schema apply" + ); + assert!( + refused.authorization.is_none(), + "refusal must precede effects" + ); + assert!( + file_bytes(&graph) == before_graph, + "no graph recovery or schema writes" + ); + assert_eq!( + fs::read(&ledger).unwrap(), + before_ledger, + "no ledger writes" + ); + + // The existing explicit storage-holder path keeps its recovery behavior. + let legacy = Box::pin(apply_config_dir(dir.path())).await; + assert!(legacy.ok && legacy.converged, "{:?}", legacy.diagnostics); + assert_eq!(fs::read_dir(graph.join("__recovery")).unwrap().count(), 0); + let recovered = Box::pin(Omnigraph::open_read_only(uri)).await.unwrap(); + assert!(recovered.schema_source().contains("email")); + let result = Box::pin(recovered.query( + "main", + "query names() { match { $p: Person } return { $p.name } }", + "names", + &Default::default(), + )) + .await + .unwrap(); + assert_eq!( + result.num_rows(), + 1, + "original data write recovers through Tier 0" + ); +} diff --git a/crates/omnigraph-policy/src/lib.rs b/crates/omnigraph-policy/src/lib.rs index d7c98a993..2f899cb8f 100644 --- a/crates/omnigraph-policy/src/lib.rs +++ b/crates/omnigraph-policy/src/lib.rs @@ -71,6 +71,10 @@ pub enum PolicyAction { /// is double-gated: `invoke_query` to reach the tool, plus `change` for /// the write itself. InvokeQuery, + /// Change the cluster's configuration, including policy membership, + /// graph creation, and the initial schema of a newly created graph. + /// Existing graph schema changes additionally require `schema_apply`. + ConfigManage, } impl PolicyAction { @@ -86,6 +90,7 @@ impl PolicyAction { Self::Admin => "admin", Self::GraphList => "graph_list", Self::InvokeQuery => "invoke_query", + Self::ConfigManage => "config_manage", } } @@ -108,6 +113,7 @@ impl PolicyAction { pub fn resource_kind(self) -> PolicyResourceKind { match self { Self::GraphList => PolicyResourceKind::Server, + Self::ConfigManage => PolicyResourceKind::Cluster, Self::Read | Self::Export | Self::Change @@ -130,6 +136,8 @@ pub enum PolicyResourceKind { Graph, /// `Omnigraph::Server::"root"` — management actions. Server, + /// `Omnigraph::Cluster::"root"` — applied cluster configuration. + Cluster, } /// Which kind of policy file the caller is loading. Drives the @@ -150,6 +158,9 @@ pub enum PolicyEngineKind { /// actions whose `resource_kind()` is `PolicyResourceKind::Server` /// are allowed. Server, + /// The cluster-bound bundle: cluster configuration and server inventory + /// rules, each still bound to its own resource kind. + Cluster, } impl fmt::Display for PolicyAction { @@ -173,6 +184,7 @@ impl FromStr for PolicyAction { "admin" => Ok(Self::Admin), "graph_list" => Ok(Self::GraphList), "invoke_query" => Ok(Self::InvokeQuery), + "config_manage" => Ok(Self::ConfigManage), other => bail!("unknown policy action '{other}'"), } } @@ -375,10 +387,12 @@ impl PolicyConfig { // a specific resource kind). let mut server_scoped = false; let mut graph_scoped = false; + let mut cluster_scoped = false; for action in &rule.allow.actions { match action.resource_kind() { PolicyResourceKind::Server => server_scoped = true, PolicyResourceKind::Graph => graph_scoped = true, + PolicyResourceKind::Cluster => cluster_scoped = true, } } if server_scoped && graph_scoped { @@ -388,6 +402,12 @@ impl PolicyConfig { rule.id ); } + if cluster_scoped && (graph_scoped || server_scoped) { + bail!( + "policy rule '{}' mixes cluster configuration actions with other resource kinds; split into separate rules", + rule.id + ); + } if server_scoped && (rule.allow.branch_scope.is_some() || rule.allow.target_branch_scope.is_some()) { @@ -507,6 +527,18 @@ impl PolicyEngine { PolicyCompiler::compile(&config, SERVER_RESOURCE_ID) } + /// Load the bundle bound to `cluster` in cluster configuration. + /// Configuration and server-inventory actions use distinct Cedar resources. + pub fn load_cluster(path: &Path) -> Result { + Self::load_cluster_from_source(&fs::read_to_string(path)?) + } + + pub fn load_cluster_from_source(source: &str) -> Result { + let config = PolicyConfig::from_source(source)?; + validate_kind_alignment(&config, PolicyEngineKind::Cluster)?; + PolicyCompiler::compile(&config, CLUSTER_RESOURCE_ID) + } + /// Evaluate a request. `actor_id` is supplied as a separate /// argument (not inside `PolicyRequest`) so the type system enforces /// the "server-authoritative actor identity" invariant — clients @@ -532,6 +564,7 @@ impl PolicyEngine { let resource = match request.action.resource_kind() { PolicyResourceKind::Server => entity_uid("Server", SERVER_RESOURCE_ID)?, PolicyResourceKind::Graph => entity_uid("Graph", &self.graph_id)?, + PolicyResourceKind::Cluster => entity_uid("Cluster", CLUSTER_RESOURCE_ID)?, }; let context_value = json!({ "has_branch": request.branch.is_some(), @@ -643,16 +676,21 @@ impl PolicyEngine { /// a server file fails at load time instead of compiling cleanly /// and never matching a request. fn validate_kind_alignment(config: &PolicyConfig, kind: PolicyEngineKind) -> Result<()> { - let required = match kind { - PolicyEngineKind::Graph => PolicyResourceKind::Graph, - PolicyEngineKind::Server => PolicyResourceKind::Server, - }; for rule in &config.rules { for action in &rule.allow.actions { - if action.resource_kind() != required { + let allowed = match kind { + PolicyEngineKind::Graph => action.resource_kind() == PolicyResourceKind::Graph, + PolicyEngineKind::Server => action.resource_kind() == PolicyResourceKind::Server, + PolicyEngineKind::Cluster => matches!( + action.resource_kind(), + PolicyResourceKind::Cluster | PolicyResourceKind::Server + ), + }; + if !allowed { let (got, expected_file) = match action.resource_kind() { PolicyResourceKind::Server => ("server-scoped", "server policy file"), PolicyResourceKind::Graph => ("per-graph", "per-graph policy file"), + PolicyResourceKind::Cluster => ("cluster-scoped", "cluster policy file"), }; bail!( "policy rule '{}' uses {} action '{}' in a {:?} policy file; \ @@ -731,6 +769,17 @@ fn compile_entities(config: &PolicyConfig, graph_id: &str, schema: &Schema) -> R HashSet::::new(), )?); } + if config + .rules + .iter() + .any(|rule| rule.allow.actions.contains(&PolicyAction::ConfigManage)) + { + entities.push(Entity::new( + entity_uid("Cluster", CLUSTER_RESOURCE_ID)?, + HashMap::new(), + HashSet::::new(), + )?); + } Ok(Entities::from_entities(entities, Some(schema))?) } @@ -781,6 +830,9 @@ fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, graph_id: &st PolicyResourceKind::Server => { format!("Omnigraph::Server::{}", cedar_literal(SERVER_RESOURCE_ID)) } + PolicyResourceKind::Cluster => { + format!("Omnigraph::Cluster::{}", cedar_literal(CLUSTER_RESOURCE_ID)) + } }; format!( @@ -841,6 +893,7 @@ namespace Omnigraph { entity Group; entity Graph; entity Server; + entity Cluster; action "read" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; action "export" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; @@ -853,6 +906,7 @@ namespace Omnigraph { action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext }; + action "config_manage" appliesTo { principal: Actor, resource: Cluster, context: RequestContext }; } "# } @@ -861,6 +915,7 @@ namespace Omnigraph { /// (the running server); the id is fixed at `"root"` so Cedar rules can /// reference it unambiguously: `resource == Omnigraph::Server::"root"`. const SERVER_RESOURCE_ID: &str = "root"; +const CLUSTER_RESOURCE_ID: &str = "root"; fn entity_uid(entity_type: &str, id: &str) -> Result { let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?; @@ -1096,7 +1151,7 @@ rules: } use super::{ PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest, - PolicyTestCase, PolicyTestConfig, + PolicyResourceKind, PolicyTestCase, PolicyTestConfig, }; #[test] @@ -1674,4 +1729,81 @@ rules: .unwrap(); assert!(decision.allowed); } + + #[test] + fn config_manage_is_explicit_cluster_authority_not_graph_admin() { + let source = r#" +version: 1 +groups: + owners: [principal:owner] + readers: [principal:reader] +rules: + - id: manage-configuration + allow: + actors: {group: owners} + actions: [config_manage] + - id: inspect-registry + allow: + actors: {group: readers} + actions: [graph_list] +"#; + let policy = PolicyEngine::load_cluster_from_source(source).unwrap(); + let request = |action| PolicyRequest { + action, + branch: None, + target_branch: None, + }; + assert!( + policy + .authorize("principal:owner", &request(PolicyAction::ConfigManage)) + .unwrap() + .allowed + ); + assert!( + !policy + .authorize("principal:reader", &request(PolicyAction::ConfigManage)) + .unwrap() + .allowed + ); + assert!( + !policy + .authorize("principal:unknown", &request(PolicyAction::ConfigManage)) + .unwrap() + .allowed + ); + assert!( + policy + .authorize("principal:reader", &request(PolicyAction::GraphList)) + .unwrap() + .allowed + ); + assert!(PolicyEngine::load_server_from_source(source).is_err()); + assert!(PolicyEngine::load_graph_from_source(source, "graph").is_err()); + assert_eq!( + PolicyAction::ConfigManage.resource_kind(), + PolicyResourceKind::Cluster + ); + assert_eq!( + PolicyAction::Admin.resource_kind(), + PolicyResourceKind::Graph + ); + assert_eq!( + "config_manage".parse::().unwrap(), + PolicyAction::ConfigManage + ); + for scope in ["branch_scope", "target_branch_scope"] { + let scoped = source.replace( + "actions: [config_manage]", + &format!("actions: [config_manage]\n {scope}: any"), + ); + assert!(PolicyEngine::load_cluster_from_source(&scoped).is_err()); + } + for action in ["read", "graph_list"] { + let mixed = source.replace( + "actions: [config_manage]", + &format!("actions: [config_manage, {action}]"), + ); + assert!(PolicyEngine::load_cluster_from_source(&mixed).is_err()); + } + } } diff --git a/crates/omnigraph-server/src/data_tokens.rs b/crates/omnigraph-server/src/data_tokens.rs index 8bb1c4632..1bd640be3 100644 --- a/crates/omnigraph-server/src/data_tokens.rs +++ b/crates/omnigraph-server/src/data_tokens.rs @@ -63,6 +63,25 @@ pub struct DataTokenClaims { pub grants: Vec, } +/// Version 2 authenticates identity only. All graph permissions come from +/// applied policy; strict parsing refuses permission or membership claims. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityTokenClaims { + 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, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DataTokenHeader { @@ -207,9 +226,9 @@ impl DataTokenTrust { .map(|actor| actor.actor().clone()) } - /// Verify using an explicit admission time and retain the signed grant - /// ceiling. Failure is deliberately opaque: callers must not log a - /// credential or expose its unverified claims. + /// Verify using an explicit admission time and retain the exact signed + /// profile and any legacy ceiling. Failure is deliberately opaque: callers + /// must not log a credential or expose its unverified claims. #[must_use] pub fn verify_authenticated_at(&self, token: &str, now: u64) -> Option { if token.len() > MAX_TOKEN_BYTES { @@ -236,11 +255,44 @@ impl DataTokenTrust { ) .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; + // The discriminator only chooses a parser. Reparse the original bytes + // into the strict profile so duplicate and unknown fields still fail. + let discriminator: serde_json::Value = serde_json::from_slice(&claims_bytes).ok()?; + match discriminator.get("version")?.as_u64()? { + 1 => { + let claims: DataTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; + self.valid_claims(&claims, now) + .then(|| AuthenticatedActor::signed(claims)) + } + 2 => { + let claims: IdentityTokenClaims = serde_json::from_slice(&claims_bytes).ok()?; + self.valid_identity_claims(&claims, now) + .then(|| AuthenticatedActor::signed_identity(claims)) + } + _ => None, } - Some(AuthenticatedActor::signed(claims)) + } + + fn valid_identity_claims(&self, claims: &IdentityTokenClaims, now: u64) -> bool { + let Some(ttl) = claims.exp.checked_sub(claims.iat) else { + return false; + }; + claims.version == 2 + && 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) + && matches!( + (claims.principal_kind, claims.assurance), + (PrincipalKind::Human, DataAssurance::VerifiedHuman) + | (PrincipalKind::Automation, DataAssurance::VerifiedWorkload) + ) } fn valid_claims(&self, claims: &DataTokenClaims, now: u64) -> bool { diff --git a/crates/omnigraph-server/src/data_tokens/tests.rs b/crates/omnigraph-server/src/data_tokens/tests.rs index 7aff8e44f..2ab95776b 100644 --- a/crates/omnigraph-server/src/data_tokens/tests.rs +++ b/crates/omnigraph-server/src/data_tokens/tests.rs @@ -74,6 +74,58 @@ fn issuer_golden_signature_and_per_graph_ceiling() { ); } +#[test] +fn identity_profile_preserves_bindings_and_rejects_permissions() { + let fixture = golden(); + let now = fixture["verification_time"].as_u64().unwrap(); + let trust = trust(); + let mut claims = fixture["claims"].clone(); + claims["version"] = json!(2); + claims.as_object_mut().unwrap().remove("grants"); + let mut actor = trust.verify_authenticated_at(&sign(&claims), now).unwrap(); + assert!(actor.data_claims().is_none()); + assert_eq!( + serde_json::to_value(actor.identity_claims().unwrap()).unwrap(), + claims + ); + assert!(actor.select_graph(&GraphId::try_from("other").unwrap())); + assert!(actor.permits_action(PolicyAction::SchemaApply)); + for (field, value) in [ + ("grants", fixture["claims"]["grants"].clone()), + ("grants", json!([])), + ("roles", json!(["admin"])), + ("actions", json!(["read"])), + ("groups", json!(["admins"])), + ("version", json!(1)), + ("version", json!(3)), + ("iss", json!("https://other.example")), + ("aud", json!("urn:omnigraph:data:other")), + ("account_id", json!("other")), + ("cluster_id", json!("other")), + ("cluster_incarnation", json!("other")), + ("sub", json!("email@example.com")), + ("jti", json!("")), + ("principal_kind", json!("development")), + ("assurance", json!("verified_workload")), + ("iat", json!(now + 31)), + ("exp", json!(now)), + ("exp", json!(claims["iat"].as_u64().unwrap() + 86401)), + ] { + let mut bad = claims.clone(); + bad[field] = value; + assert!( + trust.verify_authenticated_at(&sign(&bad), now).is_none(), + "accepted {field}" + ); + } + let duplicate = claims.to_string().replacen('{', "{\"version\":2,", 1); + assert!( + trust + .verify_at(&sign_raw(&fixture["header"].to_string(), &duplicate), now) + .is_none() + ); +} + #[test] fn signed_profile_rejects_invalid_authority_and_unsupported_claims() { let fixture = golden(); @@ -105,6 +157,10 @@ fn signed_profile_rejects_invalid_authority_and_unsupported_claims() { "grants", json!([{"graph_id":"graph-a","actions":["schema_apply"]}]), ), + ( + "grants", + json!([{"graph_id":"graph-a","actions":["config_manage"]}]), + ), ( "grants", json!([{"graph_id":"graph-a","actions":["read","read"]}]), diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 710736c3c..6594dd70e 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -41,7 +41,7 @@ pub(crate) async fn server_health() -> Json { /// Unauthenticated, and therefore minimal: it reports whether this replica /// is serving or draining, the applied `config_digest` it booted from, the /// ledger revision and CAS it read, and how many graphs it serves and does -/// not serve. Graph ids are topology and stay behind `GET /graphs`. Answers +/// not serve. Graph ids stay behind authenticated catalog endpoints. Answers /// 503 once shutdown has begun; `/healthz` stays 200 while the process is /// alive. #[utoipa::path( @@ -147,6 +147,49 @@ pub(crate) async fn server_graphs_list( })) } +#[utoipa::path( + get, + path = "/graphs/discovery", + tag = "management", + operation_id = "discoverGraphs", + responses( + (status = 200, description = "Authenticated minimal graph inventory", body = GraphDiscoveryResponse), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Identity credential required", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +pub(crate) async fn server_graphs_discovery( + State(state): State, + actor: Option>, +) -> std::result::Result, ApiError> { + let actor = actor.ok_or_else(|| ApiError::unauthorized("missing bearer token"))?; + if actor.identity_claims().is_none() { + return Err(ApiError::forbidden( + "graph discovery requires a version 2 identity credential", + )); + } + // Both sets come from the accepted boot inventory. Never scan storage or + // include per-graph status, roots, diagnostics, schema, or policy contents. + let ids: std::collections::BTreeSet = state + .routing() + .registry + .list() + .into_iter() + .map(|handle| handle.key.graph_id.as_str().to_owned()) + .chain(state.quarantined_graphs()) + .collect(); + Ok(Json(GraphDiscoveryResponse { + graphs: ids + .into_iter() + .map(|graph_id| GraphDiscoveryEntry { + display_name: graph_id.clone(), + graph_id, + }) + .collect(), + })) +} + pub(crate) async fn server_openapi( State(state): State, ) -> Json { @@ -177,7 +220,7 @@ const CLUSTER_OPERATION_ID_PREFIX: &str = "cluster_"; /// always-flat endpoints. `/graphs` is the management enumeration — /// it lives at the root in both single mode (405) and multi mode, and /// must never be rewritten to `/graphs/{graph_id}/graphs`. -const ALWAYS_FLAT_PATHS: &[&str] = &["/healthz", "/readyz", "/graphs"]; +const ALWAYS_FLAT_PATHS: &[&str] = &["/healthz", "/readyz", "/graphs", "/graphs/discovery"]; /// In multi-mode `server_openapi`, every protected path-item is /// reattached under the cluster prefix. Operation IDs gain the diff --git a/crates/omnigraph-server/src/identity.rs b/crates/omnigraph-server/src/identity.rs index 3f0fec09d..dbecb5419 100644 --- a/crates/omnigraph-server/src/identity.rs +++ b/crates/omnigraph-server/src/identity.rs @@ -137,7 +137,8 @@ impl fmt::Display for GraphKey { } /// Authorization shape. Static credentials use Cedar without an additional -/// grant ceiling; signed data credentials retain their exact grants. +/// grant ceiling; version 1 signed credentials retain exact grants, while +/// version 2 credentials authenticate identity for applied policy evaluation. #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[non_exhaustive] pub enum Scope { @@ -145,6 +146,8 @@ pub enum Scope { Full, /// Exact graph/action ceilings are retained in the authenticated claims. DataToken, + /// Cluster-bound identity; graph permissions come only from applied policy. + IdentityToken, } /// How the server authenticated the actor. @@ -188,7 +191,7 @@ impl ResolvedActor { } } -/// Verified request identity and its immutable signed grant ceiling. +/// Verified request identity and its immutable signed credential profile. /// /// Public identity fields are exposed only through a shared projection. There /// is no public constructor or mutable projection: a caller-created @@ -196,10 +199,16 @@ impl ResolvedActor { #[derive(Debug, Clone)] pub struct AuthenticatedActor { actor: ResolvedActor, - data_token: Option>, + data_token: Option, selected_graph: Option, } +#[derive(Debug, Clone)] +enum VerifiedDataToken { + Restricted(Arc), + Identity(Arc), +} + impl std::ops::Deref for AuthenticatedActor { type Target = ResolvedActor; @@ -225,7 +234,20 @@ impl AuthenticatedActor { scopes: vec![Scope::DataToken], source: AuthSource::SignedData, }, - data_token: Some(Arc::new(claims)), + data_token: Some(VerifiedDataToken::Restricted(Arc::new(claims))), + selected_graph: None, + } + } + + pub(crate) fn signed_identity(claims: crate::data_tokens::IdentityTokenClaims) -> Self { + Self { + actor: ResolvedActor { + actor_id: Arc::from(format!("principal:{}", claims.sub)), + tenant_id: None, + scopes: vec![Scope::IdentityToken], + source: AuthSource::SignedData, + }, + data_token: Some(VerifiedDataToken::Identity(Arc::new(claims))), selected_graph: None, } } @@ -237,14 +259,25 @@ impl AuthenticatedActor { /// Authenticated signed claims, excluding the original bearer plaintext. pub fn data_claims(&self) -> Option<&crate::data_tokens::DataTokenClaims> { - self.data_token.as_deref() + match &self.data_token { + Some(VerifiedDataToken::Restricted(claims)) => Some(claims), + _ => None, + } + } + + /// Authenticated version 2 identity metadata, never graph permission grants. + pub fn identity_claims(&self) -> Option<&crate::data_tokens::IdentityTokenClaims> { + match &self.data_token { + Some(VerifiedDataToken::Identity(claims)) => Some(claims), + _ => None, + } } pub(crate) fn select_graph(&mut self, graph_id: &GraphId) -> bool { - if self.source == AuthSource::Static { + if self.source == AuthSource::Static || self.identity_claims().is_some() { return true; } - if self.data_token.as_ref().is_some_and(|claims| { + if self.data_claims().is_some_and(|claims| { claims .grants .iter() @@ -257,10 +290,10 @@ impl AuthenticatedActor { } pub(crate) fn permits_action(&self, action: omnigraph_policy::PolicyAction) -> bool { - if self.source == AuthSource::Static { + if self.source == AuthSource::Static || self.identity_claims().is_some() { return true; } - self.data_token.as_ref().is_some_and(|claims| { + self.data_claims().is_some_and(|claims| { claims.grants.iter().any(|grant| { (self.selected_graph.as_ref() == Some(&grant.graph_id) || (self.selected_graph.is_none() @@ -272,7 +305,8 @@ impl AuthenticatedActor { pub(crate) fn permits_graph_listing(&self, graph_id: &str) -> bool { self.source == AuthSource::Static - || self.data_token.as_ref().is_some_and(|claims| { + || self.identity_claims().is_some() + || self.data_claims().is_some_and(|claims| { claims.grants.iter().any(|grant| { grant.graph_id.as_str() == graph_id && grant diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 88ac13166..65f430793 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -33,11 +33,11 @@ use api::{ BlobReadQuery, BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, GraphBatchLoadOutput, - GraphBatchLoadQuery, GraphInfo, GraphListResponse, HealthOutput, IngestOutput, IngestRequest, - InvokeStoredQueryRequest, InvokeStoredQueryResponse, LegacyReadOutput, QueriesCatalogOutput, - QueryRequest, ReadOutput, ReadRequest, ReadinessOutput, SchemaApplyOutput, SchemaApplyRequest, - SchemaOutput, SnapshotQuery, graph_batch_load_receipt_output, ingest_receipt_output, - schema_apply_output, snapshot_payload, + GraphBatchLoadQuery, GraphDiscoveryEntry, GraphDiscoveryResponse, GraphInfo, GraphListResponse, + HealthOutput, IngestOutput, IngestRequest, InvokeStoredQueryRequest, InvokeStoredQueryResponse, + LegacyReadOutput, QueriesCatalogOutput, QueryRequest, ReadOutput, ReadRequest, ReadinessOutput, + SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotQuery, + graph_batch_load_receipt_output, ingest_receipt_output, schema_apply_output, snapshot_payload, }; pub use auth::{AWS_SECRET_ENV, EnvOrFileTokenSource, TokenSource, resolve_token_source}; use axum::body::{Body, Bytes}; @@ -100,6 +100,7 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash { handlers::server_health, handlers::server_ready, handlers::server_graphs_list, + handlers::server_graphs_discovery, handlers::server_snapshot, handlers::server_blob_get, handlers::server_blob_head, @@ -1972,6 +1973,7 @@ pub fn build_app(state: AppState) -> Router { // exposed — operators run `cluster apply` and restart. let management = Router::new() .route("/graphs", get(server_graphs_list)) + .route("/graphs/discovery", get(server_graphs_discovery)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -2146,8 +2148,8 @@ pub async fn open_multi_graph_state( // resource-model refactor maps to the singleton // `Omnigraph::Server::"root"` entity at evaluation time. let server_policy = match server_policy_source { - Some(PolicySource::File(path)) => Some(PolicyEngine::load_server(path)?), - Some(PolicySource::Inline(source)) => Some(PolicyEngine::load_server_from_source(source)?), + Some(PolicySource::File(path)) => Some(PolicyEngine::load_cluster(path)?), + Some(PolicySource::Inline(source)) => Some(PolicyEngine::load_cluster_from_source(source)?), None => None, }; diff --git a/crates/omnigraph-server/tests/auth_policy.rs b/crates/omnigraph-server/tests/auth_policy.rs index ed2c24e63..70a166688 100644 --- a/crates/omnigraph-server/tests/auth_policy.rs +++ b/crates/omnigraph-server/tests/auth_policy.rs @@ -22,6 +22,112 @@ use tower::ServiceExt; mod support; use support::*; +#[tokio::test(flavor = "multi_thread")] +async fn identity_discovery_exposes_only_existence_and_policy_controls_schema() { + let tokens = data_tokens::DataTokens::new(); + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let identity = tokens.identity_token(); + let restricted = tokens.token(json!([{"graph_id":"default","actions":["read","graph_list"]}])); + 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()) + .with_boot_witness( + omnigraph_server::BootWitness { + applied_graphs: vec!["default".into(), "unavailable".into()], + ..Default::default() + }, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::time::Duration::from_secs(30), + ); + let app = build_app(state); + let (status, catalog) = json_response(&app, get_request("/graphs/discovery", &identity)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + catalog, + json!({"graphs":[ + {"graph_id":"default","display_name":"default"}, + {"graph_id":"unavailable","display_name":"unavailable"} + ]}) + ); + for token in [&restricted, "static-token"] { + let (status, _) = json_response(&app, get_request("/graphs/discovery", token)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "legacy credentials cannot escape their catalog contract" + ); + } + for path in [ + "/graphs", + "/graphs/default/schema", + "/graphs/default/snapshot", + ] { + let (status, _) = json_response(&app, get_request(path, &identity)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "existence is not permission: {path}" + ); + } + let (status, _) = json_response( + &app, + get_request("/graphs/discovery", "invalid.jwt.signature"), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let policy_path = temp.path().join("policy.yaml"); + for actors in [vec!["someone-else"], vec![tokens.actor.as_str()]] { + fs::write(&policy_path, permit_all_policy_yaml(&actors)).unwrap(); + let state = 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 app = build_app(state); + let expected = if actors[0] == tokens.actor { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + let (status, _) = json_response(&app, get_request("/graphs/discovery", &identity)).await; + assert_eq!( + status, + StatusCode::OK, + "policy enrollment cannot hide existence" + ); + let (status, _) = + json_response(&app, get_request("/graphs/default/schema", &identity)).await; + assert_eq!(status, expected, "the same token follows activated policy"); + let request = Request::builder() + .method(Method::POST) + .uri(g("/schema/apply")) + .header("authorization", format!("Bearer {identity}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&SchemaApplyRequest { + schema_source: fs::read_to_string(fixture("test.pg")).unwrap(), + ..Default::default() + }) + .unwrap(), + )) + .unwrap(); + let (status, _) = json_response(&app, request).await; + assert_eq!( + status, expected, + "identity credentials neither grant nor ban schema apply" + ); + } +} + #[tokio::test(flavor = "multi_thread")] async fn signed_data_tokens_narrow_policy_and_attribute_writes() { let tokens = data_tokens::DataTokens::new(); diff --git a/crates/omnigraph-server/tests/boot_settings.rs b/crates/omnigraph-server/tests/boot_settings.rs index f36536296..b7bb45f0c 100644 --- a/crates/omnigraph-server/tests/boot_settings.rs +++ b/crates/omnigraph-server/tests/boot_settings.rs @@ -14,6 +14,33 @@ use tower::ServiceExt; mod support; use support::*; +#[tokio::test] +async fn cluster_management_policy_can_boot_beside_legacy_catalog_rules() { + let temp = tempfile::tempdir().unwrap(); + let source = omnigraph_server::PolicySource::Inline( + "version: 1\ngroups:\n operators: [operator]\nrules:\n - id: inventory\n allow:\n actors: {group: operators}\n actions: [graph_list]\n - id: configuration\n allow:\n actors: {group: operators}\n actions: [config_manage]\n".into(), + ); + let state = omnigraph_server::open_multi_graph_state( + Vec::new(), + vec![("operator".into(), "static-token".into())], + Some(&source), + temp.path().join("cluster.yaml"), + false, + ) + .await + .unwrap(); + let app = build_app(state); + let (status, payload) = json_response(&app, get_request("/graphs", "static-token")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload, serde_json::json!({"graphs":[]})); + let (status, _) = json_response(&app, get_request("/graphs/discovery", "static-token")).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "new cluster policies do not reclassify static credentials" + ); +} + /// External consumers may construct and exhaustively destructure the legacy /// public settings and identity records without opting into managed trust. #[test] diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index a400d039b..1b0e63c21 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -186,6 +186,7 @@ const EXPECTED_PATHS: &[&str] = &[ "/healthz", "/readyz", "/graphs", + "/graphs/discovery", "/graphs/{graph_id}/snapshot", "/graphs/{graph_id}/blob", "/graphs/{graph_id}/read", @@ -2253,7 +2254,7 @@ async fn multi_mode_openapi_keeps_management_paths_flat() { .unwrap(); let (_, json) = json_response(&app, request).await; let paths = json["paths"].as_object().unwrap(); - for flat in ["/healthz", "/graphs"] { + for flat in ["/healthz", "/graphs", "/graphs/discovery"] { assert!( paths.contains_key(flat), "{flat} must remain flat in multi mode" @@ -2281,7 +2282,10 @@ async fn multi_mode_openapi_prefixes_operation_ids_with_cluster() { let paths = json["paths"].as_object().unwrap(); let mut checked = 0; for (path, item) in paths { - if path == "/healthz" || path == "/readyz" || path == "/graphs" { + if matches!( + path.as_str(), + "/healthz" | "/readyz" | "/graphs" | "/graphs/discovery" + ) { continue; } for method in ["get", "head", "post", "put", "delete", "patch"] { @@ -2344,7 +2348,7 @@ async fn multi_mode_openapi_declares_graph_id_path_parameter() { } } - for flat in ["/healthz", "/graphs"] { + for flat in ["/healthz", "/graphs", "/graphs/discovery"] { let item = paths.get(flat).unwrap(); for method in ["get", "head", "post", "put", "delete", "patch"] { if let Some(operation) = item.get(method).filter(|value| value.is_object()) { diff --git a/crates/omnigraph-server/tests/support/data_tokens.rs b/crates/omnigraph-server/tests/support/data_tokens.rs index bebce9a7c..20a4456c0 100644 --- a/crates/omnigraph-server/tests/support/data_tokens.rs +++ b/crates/omnigraph-server/tests/support/data_tokens.rs @@ -37,6 +37,14 @@ impl DataTokens { } pub fn token(&self, grants: Value) -> String { + self.signed(Some(grants)) + } + + pub fn identity_token(&self) -> String { + self.signed(None) + } + + fn signed(&self, grants: Option) -> String { let mut claims = self.fixture["claims"].clone(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -44,7 +52,12 @@ impl DataTokens { .as_secs(); claims["iat"] = json!(now); claims["exp"] = json!(now + 3600); - claims["grants"] = grants; + if let Some(grants) = grants { + claims["grants"] = grants; + } else { + claims["version"] = json!(2); + claims.as_object_mut().unwrap().remove("grants"); + } let content = format!( "{}.{}", URL_SAFE_NO_PAD.encode(self.fixture["header"].to_string()), diff --git a/crates/omnigraph/src/db/manifest.rs b/crates/omnigraph/src/db/manifest.rs index f5887ad42..9a5d04fc3 100644 --- a/crates/omnigraph/src/db/manifest.rs +++ b/crates/omnigraph/src/db/manifest.rs @@ -78,8 +78,8 @@ pub(crate) use recovery::{ heal_pending_sidecars_roll_forward, list_sidecars, new_branch_merge_sidecar_v9, new_ensure_indices_sidecar_v9, new_occ_sidecar_v9, new_optimize_sidecar_v9, new_schema_apply_sidecar_v9, new_system_column_upgrade_sidecar_v9, - recover_failed_branch_merge_under_gates, recover_manifest_drift, schema_apply_serial_queue_key, - write_sidecar, + recover_failed_branch_merge_under_gates, recover_manifest_drift, refuse_pending_recovery, + schema_apply_serial_queue_key, write_sidecar, }; pub use state::DatasetEntry; #[cfg(test)] diff --git a/crates/omnigraph/src/db/manifest/recovery.rs b/crates/omnigraph/src/db/manifest/recovery.rs index 0db1a6ba6..7877ca396 100644 --- a/crates/omnigraph/src/db/manifest/recovery.rs +++ b/crates/omnigraph/src/db/manifest/recovery.rs @@ -1308,6 +1308,46 @@ pub(crate) async fn list_sidecars( Ok(out) } +/// Nonmutating absence proof for callers without recovery authority. Every +/// JSON object blocks, including malformed and future sidecars; there is no +/// need to read or interpret a body to establish that absence is unproven. +pub(crate) async fn refuse_pending_recovery( + root_uri: &str, + storage: &dyn StorageAdapter, +) -> Result<()> { + fail(&RECOVERY_SIDECAR_LIST)?; + let pending = storage + .list_dir_bounded( + &recovery_dir_uri(root_uri), + ".json", + crate::storage::ListDirBounds { + max_matching_entries: 1, + max_irrelevant_entries: 1024, + max_uri_bytes: 131_072, + }, + ) + .await?; + if !pending.is_empty() { + return Err(OmniError::recovery_required( + "pending-recovery", + "graph has pending recovery; resolve it with explicit recovery authority before retrying", + )); + } + for staging in [ + crate::db::schema_state::schema_source_staging_uri(root_uri), + crate::db::schema_state::schema_ir_staging_uri(root_uri), + crate::db::schema_state::schema_state_staging_uri(root_uri), + ] { + if storage.exists(&staging).await? { + return Err(OmniError::recovery_required( + "pending-schema-recovery", + "graph has staged schema recovery; resolve it with explicit recovery authority before retrying", + )); + } + } + Ok(()) +} + /// Best-effort discovery for the non-mutating read-only schema-coherence /// guard. ReadOnly historically skips recovery classification entirely, so a /// corrupt/future sidecar must not make an otherwise coherent read fail. Valid diff --git a/crates/omnigraph/src/db/omnigraph.rs b/crates/omnigraph/src/db/omnigraph.rs index 42c9f4540..99fac34b8 100644 --- a/crates/omnigraph/src/db/omnigraph.rs +++ b/crates/omnigraph/src/db/omnigraph.rs @@ -693,6 +693,26 @@ impl Omnigraph { Self::open_with_storage_and_mode(uri, storage_for_uri(uri)?, OpenMode::ReadOnly).await } + /// Observe that no recovery sidecar or staged schema artifact is present. + /// Performs no graph open, recovery, cleanup, or object-body reads. Any + /// pending JSON, including malformed or unsupported sidecars, refuses. + /// Listing refuses beyond one matching file, 1,024 unrelated entries or + /// 128 KiB of URI bytes; three fixed schema-staging paths are also probed. + /// + /// This is a point-in-time observation under the process-local schema gate, + /// not writer exclusion or a transferable recovery capability. Callers must + /// retain their existing writer exclusion through any subsequent effect. + pub async fn ensure_no_pending_recovery(uri: &str) -> Result<()> { + let root = normalize_root_uri(uri)?; + let storage = storage_for_uri(&root)?; + let identity = write_queue_root_identity(&root)?; + let queues = crate::db::write_queue::WriteQueueManager::for_root(&identity); + let _schema_gate = queues + .acquire(&crate::db::manifest::schema_apply_serial_queue_key()) + .await; + crate::db::manifest::refuse_pending_recovery(&root, storage.as_ref()).await + } + /// Whether the selected graph-manifest dataset references files outside /// its own root through Lance `base_paths`. /// diff --git a/crates/omnigraph/tests/forbidden_apis.rs b/crates/omnigraph/tests/forbidden_apis.rs index eae7d7ad1..52f821ea9 100644 --- a/crates/omnigraph/tests/forbidden_apis.rs +++ b/crates/omnigraph/tests/forbidden_apis.rs @@ -256,6 +256,7 @@ write_surfaces! { const READ_ONLY_SURFACES: &[(&str, &str)] = &[ ("db/omnigraph.rs", "open_read_only"), ("db/omnigraph.rs", "open_read_only_with_storage"), + ("db/omnigraph.rs", "ensure_no_pending_recovery"), ("db/omnigraph.rs", "manifest_has_external_base_paths"), ("db/omnigraph/export.rs", "capture_served_export_cut"), ( diff --git a/crates/omnigraph/tests/recovery_probe.rs b/crates/omnigraph/tests/recovery_probe.rs new file mode 100644 index 000000000..4b59ec23a --- /dev/null +++ b/crates/omnigraph/tests/recovery_probe.rs @@ -0,0 +1,91 @@ +//! Recovery inspection is bounded and never repairs the inspected graph. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use omnigraph::db::Omnigraph; + +fn files(root: &Path) -> BTreeMap> { + fn visit(root: &Path, dir: &Path, found: &mut BTreeMap>) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(root, &path, found); + } else { + found.insert( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + ); + } + } + } + let mut found = BTreeMap::new(); + visit(root, root, &mut found); + found +} + +#[tokio::test] +async fn recovery_probe_refuses_unknown_sidecars_and_staging_without_writes() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().to_str().unwrap(); + Omnigraph::init(uri, "node Person { name: String @key }") + .await + .unwrap(); + let before = files(dir.path()); + Omnigraph::ensure_no_pending_recovery(uri).await.unwrap(); + assert_eq!(files(dir.path()), before); + + let recovery = dir.path().join("__recovery"); + fs::create_dir_all(&recovery).unwrap(); + let pending = recovery.join("unknown.json"); + fs::write(&pending, "malformed future recovery data").unwrap(); + let before = files(dir.path()); + let err = Omnigraph::ensure_no_pending_recovery(uri) + .await + .unwrap_err(); + assert!(err.to_string().contains("pending recovery"), "{err}"); + assert_eq!(files(dir.path()), before); + fs::remove_file(pending).unwrap(); + + for name in [ + "_schema.pg.staging", + "_schema.ir.json.staging", + "__schema_state.json.staging", + ] { + let pending = dir.path().join(name); + fs::write(&pending, "uncertain staging").unwrap(); + let before = files(dir.path()); + let err = Omnigraph::ensure_no_pending_recovery(uri) + .await + .unwrap_err(); + assert!( + err.to_string().contains("staged schema recovery"), + "{name}: {err}" + ); + assert_eq!(files(dir.path()), before); + fs::remove_file(pending).unwrap(); + } + // Existing inventory semantics ignore non-JSON residue within the bound. + fs::write(recovery.join("note.txt"), "not a recovery sidecar").unwrap(); + let before = files(dir.path()); + Omnigraph::ensure_no_pending_recovery(uri).await.unwrap(); + assert_eq!(files(dir.path()), before); +} + +#[tokio::test] +async fn recovery_probe_refuses_inventory_uncertainty_at_the_bound() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().to_str().unwrap(); + let recovery = dir.path().join("__recovery"); + fs::create_dir(&recovery).unwrap(); + for index in 0..1025 { + fs::write(recovery.join(format!("residue-{index}.txt")), []).unwrap(); + } + let before = files(dir.path()); + let err = Omnigraph::ensure_no_pending_recovery(uri) + .await + .unwrap_err(); + assert!(err.to_string().contains("limit"), "{err}"); + assert_eq!(files(dir.path()), before); +} diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index 255499607..5f0421be2 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -71,23 +71,49 @@ 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) +The optional [offline signed-token trust](../rfcs/0053-offline-data-token-verification.md) uses immutable public trust loaded before graph open. The Core's opt-in root-bound serving snapshot supplies the canonical storage root from the same resolution as the applied revision; the server checks that root against trust without reading a managed identity marker. The verifier resolves -`principal:` and retains per-graph action ceilings. Graph selection checks the ceiling before registry -lookup; the common authorization gate checks actions before Cedar, which must -explicitly permit signed identities even when no static credentials exist. -Static credential authority remains unchanged. Issuer reachability is outside -the serving request path. +`principal:` and retains a private, verified credential profile: + +- Version 1 keeps its existing per-graph action ceilings. Graph selection + checks the ceiling before registry lookup; the common authorization gate + checks actions before Cedar. +- Version 2 authenticates a cluster-bound identity and rejects permission + fields. The same common gate requires applied Cedar policy for protected + operations, with no token-derived graph/action ceiling. + +Cedar must explicitly permit either signed profile even when no static +credentials exist. Static credential authority remains unchanged. Issuer +reachability is outside the serving request path. The profile boundary and +applied-policy ownership are described in +[Identity credentials and applied policy authorization](../rfcs/2026-09-09-identity-credentials-and-applied-policy.md). + +`GET /graphs/discovery` accepts only the verified identity profile and returns +IDs and display names for the opened and quarantined graph inventory captured +at boot. It neither scans storage nor discloses availability, paths, policy, +schema, or query definitions. It needs no policy membership. The separate +`GET /graphs` metadata response and its `graph_list` gate are unchanged; +version 1 filtering remains an additional restriction. Typed discovery +responses are additive to the existing public catalog types. + +The CLI's versioned keychain cache records the issuance profile and verifies +its endpoint and identity bindings before replacement. A legacy issuance +request cannot return an identity profile, and restricted caches are never +silently upgraded. Only cached identity credentials select discovery +automatically in a managed folder. Explicit server addressing keeps the +existing catalog unless `graphs list --discovery` is requested; the CLI never +infers routing from an arbitrary bearer token's unverified shape. 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 -the shutdown signal on. Graph ids stay on the authenticated `GET /graphs`, -which also lists the quarantined ones. Graceful shutdown is bounded by one -deadline (`--shutdown-grace-seconds`, default 25), kept by a thread and armed +the shutdown signal on. Graph IDs stay on the authenticated catalog routes, +which include quarantined graphs under their respective disclosure contracts. +Graceful shutdown is bounded by one deadline (`--shutdown-grace-seconds`, +default 25), kept by a thread and armed by a listener installed before graphs open, after which the process exits 2 without claiming success. @@ -115,6 +141,15 @@ state carries verified claims and graph selection. `DataTokenTrust::verify_at` returns the identity projection for existing callers; `verify_authenticated_at` returns the opaque authenticated result used by the server. Public actor construction cannot grant signed-token permissions. +`DataTokenClaims` and `DataGrant` keep their version-1 shapes; +`IdentityTokenClaims` is a separate strict type. Read-only claim accessors on +`AuthenticatedActor` expose only the matching verified profile. + +Policy embedders must handle `PolicyAction::ConfigManage`, +`PolicyResourceKind::Cluster`, and `PolicyEngineKind::Cluster`. These public +enums are exhaustive, so an external exhaustive match must add the relevant +arm when upgrading. Preserving existing constructors and direct storage-holder +behavior does not remove that source compatibility requirement. In-process hosts that assemble `AppState` and call its existing `with_data_token_trust` method continue to own their graph/root binding. Use diff --git a/docs/releases/v0.12.0.md b/docs/releases/v0.12.0.md index 1dfd233d1..4a5a16008 100644 --- a/docs/releases/v0.12.0.md +++ b/docs/releases/v0.12.0.md @@ -11,6 +11,21 @@ Unreleased. inspect the graph before resubmitting it. See [managed data access](../user/cli/managed-data.md). +- **CLI graph errors retain their structured JSON.** With `--json` or a read + command's `--format json`, server refusals preserve their error code and + typed details on stdout. Policy denials and resource limits exit 1; + conditional mutation mismatches retain exit 4. Human diagnostics keep their + existing behavior. + +- **Identity credentials leave permissions in applied policy.** Normal + `cluster token` caches a cluster-bound identity credential with no graph or + action grants. Applied Cedar policy decides protected graph and schema + operations. Minimal graph discovery shows every effective graph ID/name + without exposing schema, roots or other operational metadata. Explicit + server addressing selects it with `graphs list --discovery`. The older + restricted credential remains available only through explicit `--actions` + requests. See [managed data access](../user/cli/managed-data.md). + - **A stale mutation or load recovery sidecar no longer makes every read-write open fail.** When a mutation's or load's sidecar update write was lost (the object store acknowledged it but the file still holds the arm-time bytes) @@ -29,6 +44,15 @@ Unreleased. ## Compatibility and behavior changes +- **Policy embedder source compatibility.** Cluster configuration authorization + adds `PolicyAction::ConfigManage`, `PolicyResourceKind::Cluster`, and + `PolicyEngineKind::Cluster`. Exhaustive matches on these public enums need + the new variants. Existing static credentials, direct storage-holder APIs + and version 1 token structs keep their behavior and construction contracts. + An existing cluster needs an explicitly applied management policy before + activating identity-authorized configuration execution; proposed policies + cannot grant permission to install themselves. + - **GQ logic tests:** several `--- seam` blocks may precede one mutate step when they name distinct seams; each is armed and recorded on its own, and the same seam twice before one step is refused. Two new skip seams, diff --git a/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md new file mode 100644 index 000000000..c7e30d6f3 --- /dev/null +++ b/docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md @@ -0,0 +1,263 @@ +--- +rfc: "2026-09-09-identity-credentials-and-applied-policy" +title: "Identity credentials and applied policy authorization" +track: maintainer +status: accepted +implementation: complete +authors: + - andrew +created: 2026-09-09 +updated: 2026-09-16 +discussion: https://github.com/ModernRelay/omnigraph/pull/691 +supersedes: [] +superseded_by: [] +blocked_on: [] +--- + +# RFC: Identity credentials and applied policy authorization + +## Summary + +Add an identity-only version 2 signed credential alongside the restricted +version 1 profile in [RFC 0053](0053-offline-data-token-verification.md). +The credential authenticates a principal for one cluster; the cluster's +applied policy configuration defines graph and schema permissions. +Authenticated users can discover every graph's existence without obtaining +permission to read its data or schema. + +Add an identity-authorized cluster planning/apply API that checks the same +policy engine against the current applied configuration before effects. +Existing direct storage-holder APIs and version 1 restrictions remain intact. +Version 2 is the normal credential profile. Version 1 remains an explicit +compatibility interface for existing restricted clients; its presence does +not require a new deployment to operate both profiles. + +## Motivation + +An explicit token ceiling is useful for restricted delegation, but requiring +every ordinary credential to duplicate graph/action grants creates another +permission list to maintain. Policy changes cannot grant an action missing +from that token, even when the same principal is already authenticated. +The version 1 action vocabulary also categorically excludes schema changes. + +Removing that ceiling alone is insufficient. Schema changes through cluster +apply must authorize their initiating principal without bypassing declarative +ownership. A candidate policy must not authorize its own installation. Graph +discovery must expose names without exposing the richer operational metadata +returned by the existing graph catalog. + +## User and operational behavior + +Normal credential acquisition requests version 2 without graph/actions: + +```text +omnigraph cluster token --ttl 3600 +omnigraph --graph knowledge query list_people +omnigraph graphs list +``` + +The implicit connected-folder list uses minimal discovery with a cached +version 2 credential. Explicit addressing keeps its existing catalog behavior; +select the new minimal endpoint with +`omnigraph graphs list --discovery --server `. +No JWT-shaped static token is reinterpreted to select a different command. + +Explicit legacy `--actions` requests retain version 1 restrictions. Existing +cached restricted credentials never become identity-only credentials through +renewal, permission denial, or omission of a field. Unsupported profiles +refuse without switching credentials or storage addressing. + +`GET /graphs/discovery` returns only +`{"graphs":[{"graph_id":"knowledge","display_name":"knowledge"}]}` +for a valid version 2 identity. It uses the effective inventory, including +unavailable graphs. No policy group or `graph_list` permit is required. Graph +data, schema, query bodies, roots, diagnostics and topology are absent. +The existing `/graphs` response and authorization retain their contract. +Version 1 credentials cannot use discovery to evade their existing filters. +Display names currently equal graph IDs. Inventory availability presumes a +running server; an invalid cluster policy can still fail existing boot +validation rather than silently disabling that validation. + +Protected operations still require applied policy. Missing policy or missing +principal membership denies them. An activated policy change governs the +next request with the same valid token; editing files alone does not change +permissions. Expiry does not cancel an already admitted request. + +## Design + +### Credential profiles + +Version 2 retains the exact signature, key, issuer, audience, principal, +account, cluster/incarnation, assurance and temporal checks of version 1. +Its claims contain `version: 2`, `iss`, `aud`, `sub`, `account_id`, +`cluster_id`, `cluster_incarnation`, `principal_kind`, `assurance`, `iat`, +`exp`, and `jti`. It contains no `grants`, roles or policy membership. +Unknown or duplicate fields refuse; removing `grants` from a version 1 +credential does not change its profile. The verifier privately represents +the profiles distinctly and preserves public version 1 construction APIs. + +The token remains bounded to 8,192 bytes, lifetime 60–86,400 seconds, default +3,600 seconds, no expiry leeway and at most 30 seconds future issue time. +Public trust and canonical-root binding retain RFC 0053's bounds. Verification +and policy evaluation have no synchronous issuer dependency. `principal:` +remains the immutable authenticated actor; caller overrides cannot replace it. + +Credential acquisition explicitly requests `version: 2`; an omitted version +retains the version 1 request contract. Version 2 responses explicitly report +their version, identity, endpoint and expiry metadata without a grant list. +The client checks the response profile and keeps separate versioned cache +metadata. The issuer must check current identity and exact cluster admission; +it must not exchange a restricted credential for a broader one. Issuer policy +enrollment and lifecycle remain outside this library's trust boundary. + +### Schema and configuration authorization + +Remote schema reads use `read`; schema application uses `schema_apply` on +the affected graph and main branch. Add cluster-scoped `config_manage` for +configuration administration, including policy/group changes and creation of +a graph with its initial schema and policy. The existing graph-scoped `admin` +action is not silently reinterpreted as this new cluster permission. + +Identity-authorized planning binds the trusted principal and checks protected +schema reads. Its authorization evidence binds the accepted base revision and +CAS, desired configuration digest, policy digests, required actions and exact +resource effects. Identity-authorized apply revalidates that base and the +whole effect set under the existing cluster lock before any graph or +configuration effect. Missing policy, denied scope, changed base or uncertain +recovery authority refuses without an authorized subset being applied. + +The current applied policy is read from its accepted catalog references. +Policy bytes from the proposed directory never authorize that proposal. +Installing a policy and exercising a newly added permit therefore requires +two separately authorized changes. Successful preflight does not bypass +schema sidecars, cluster state CAS, recovery ownership or writer exclusion. +The server continues to refuse direct schema application to cluster-owned +graphs; those schemas change through configuration and cluster apply. + +First initialization needs explicit trusted bootstrap authority bound to +the initial desired configuration and effect set. The Core also verifies +the pristine imported base. Missing policy on an initialized cluster, +including one with zero graphs, never reopens bootstrap. Adding a graph +later requires the previously applied cluster management policy. + +The additive Core entry points are `plan_config_dir_authorized`, +`authorize_apply_plan`, `apply_config_dir_authorized`, and +`authorize_plan_read`, using `IdentityAuthorization` and `PlanAuthorization`. +The read-only apply preflight supports callers that must adapt destructive +resource artifacts; apply repeats the check under the Core lock. An apply +result with absent authorization proves that this invocation stopped before +recovery, graph or catalog effects. Present evidence means those effects may +have begun; callers must also account for their own earlier effects. + +Applied policy loading permits at most 4,096 resources, 1 MiB per policy +bundle, and 8 MiB in total. Identity-authorized apply refuses outstanding +recovery records before sweeping; existing explicit storage-holder recovery +must resolve them before a fresh authorized plan. This path does not grant +new recovery authority or claim automatic recovery. + +Before protected schema previews and apply effects, the Core invokes the +engine-owned `Omnigraph::ensure_no_pending_recovery` probe for affected existing +graphs. It reads no object bodies and refuses any recovery JSON or staged +schema artifact, including malformed or unsupported residue. Inventory is +bounded to one matching entry, 1,024 unrelated entries and 128 KiB of URI +bytes; limit or storage failures also refuse. This additive observation changes +no storage format or recovery behavior. It does not fence graph writers: +callers retain the existing writer-exclusion requirement through apply. + +Stored plan and history details containing protected schema information are +not transferable read grants. Consumers must authorize the current requesting +principal against current applied policy before exposing those details. +An authenticated execution result may support a non-sensitive summary without +exposing schema contents. Source-file possession retains its separate contract. + +## Invariants + +This extends [invariants](../dev/invariants.md) 3, 8, 10, 11, 12 and 13. +One accepted state supplies the authorization basis; stale or missing authority +fails closed. Shared policy code owns decisions, and a credential or external +permission mirror cannot override it. All checks precede graph effects. +No new graph/storage format, publication door, writer fence, background queue +or Lance behavior is introduced. Direct storage possession remains its +documented trust boundary, never an automatic fallback from identity denial. + +## Compatibility and reversibility + +Version 1 tokens retain their ceilings, filtered catalog and schema exclusion. +Existing static/unauthenticated modes, direct APIs and public version 1 data +types retain their behavior. New authorized entry points are additive. +The public exhaustive policy enums gain `ConfigManage`/`Cluster` variants; +embedders with exhaustive matches must add the corresponding arms. Policy +configuration using `config_manage` requires a supporting binary; old binaries +refuse unknown actions rather than granting permission. + +Existing clusters must explicitly install a reviewed management policy and +intended memberships through their existing authorized migration path before +activating the new gate. Do not derive permissions from legacy token grants. +Downgrade requires stopping version 2 issuance, allowing existing credentials +to expire, and explicitly reverting unsupported policy configuration. + +## Alternatives + +Keep mandatory token grants: preserves attenuation but leaves two ordinary +permission lists and the categorical schema exclusion. Silently ignore legacy +grants: widens credentials and breaks delegation. Hide unauthorized graphs: +prevents basic discovery and confuses absence with lack of data access. +Let a proposed policy authorize apply: lets a caller grant itself permission. +Teach each caller to evaluate policy: duplicates semantics and permits drift. + +## Evidence and tests + +Extend the server token/auth-policy and catalog suites, CLI token/cache and +dispatch suites, policy tests, and cluster plan/apply tests. Required cases: + +- Version 1 remains restricted and unchanged; malformed cross-profile claims + and invalid signature/time/root/identity fail. +- An authenticated unenrolled principal sees every graph ID/name, including + an unavailable graph, and cannot read data/schema or private catalog fields. +- Allowed and denied schema/config effects follow the applied policy; a + candidate self-grant, wrong actor or stale base fails before effects. +- A policy activation changes permissions for the same identity credential. +- Bootstrap cannot be repeated on an initialized or policy-free cluster. +- Old CLI restriction flags, cache entries and automation exchanges cannot + silently gain authority; explicit addressing stays compatible. +- OpenAPI accurately describes the additive discovery response. + +The implementation changes authentication and authorization around existing +operations, not Lance semantics. No new substrate behavior is assumed. + +## Rollout + +Qualify identity verification, discovery, shared policy enforcement and client +compatibility before activating a supporting server and CLI. A new deployment +may enable identity-only issuance directly with its declared initial management +policy. No staged migration or concurrent legacy issuer is required. + +An existing deployment first installs reviewed policy/principal configuration +through its current authorized path and verifies management access. The +execution boundary then activates with compatible server/client versions. +Retained graphs, configuration and accepted operations require their existing +storage and ownership qualification regardless of the authentication cutover. + +The published version 1 verifier, data types and explicit restricted CLI +requests remain compatible. Issuers that continue serving restricted clients +retain their exact ceilings; unsupported requests refuse without widening them. +An issuer may stop producing version 1 credentials once it has accounted for +its clients. Retiring their verification trust also requires waiting at least +86,430 seconds after the last issuance. This bounds old credential validity; +it is not a requirement to build a legacy issuance path in new deployments. + +## Unresolved questions + +No alternative authority model is left open. Live activation and qualification +of a particular deployment remain separate from the accepted library contract. + +## Decision log + +- 2026-09-09: Proposed the separate identity profile, authenticated minimal + discovery, and authorization against applied policy, retaining legacy + restrictions and direct storage-holder behavior. +- 2026-09-16: Accepted identity-only credentials and applied-policy authority. + Replaced the staged rollout requirement with direct activation for new + deployments and explicitly scoped version 1 to published compatibility. + Adopted the date-based filename because the provisional number was allocated + to the separate explicit-storage-upgrades RFC before this proposal landed. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index f9f82ed15..08c3420e1 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -211,4 +211,5 @@ then dated RFCs by date. | [0064](0064-explicit-storage-upgrades.md) | Explicit storage upgrades | maintainer | accepted | in-progress | | [0065](0065-isolated-branch-merge-publication.md) | Isolated branch merge publication | maintainer | draft | not-started | | [0066](0066-one-seam-type.md) | One seam type for test-time behavior substitution | maintainer | draft | in-progress | +| [2026-09-09](2026-09-09-identity-credentials-and-applied-policy.md) | Identity credentials and applied policy authorization | maintainer | accepted | complete | | [2026-09-14](2026-09-14-compatibility-surfaces.md) | Compatibility surfaces | maintainer | draft | not-started | diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 669e88366..ddec842b3 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -22,10 +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. +For a managed cluster, first select it with `use` and cache an identity +credential with `cluster token`. Run `graphs list` to discover graph names, +then `query` or `mutate` from that folder with an explicit `--graph`. Applied +Cedar policy determines your permissions. See [managed data access](managed-data.md) +for legacy restricted credentials, expiry, offline operation, and clearing. ## Create, load, and query a graph diff --git a/docs/user/cli/managed-data.md b/docs/user/cli/managed-data.md index 6288de768..58c0a1077 100644 --- a/docs/user/cli/managed-data.md +++ b/docs/user/cli/managed-data.md @@ -4,43 +4,74 @@ 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: +After selecting a managed cluster with `use`, cache an identity credential: ```bash -omnigraph cluster token --graph knowledge --actions read,change,invoke_query --ttl 1h +omnigraph cluster token --ttl 1h +omnigraph graphs list 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. +The issuer must support identity credentials and admit your principal to the +selected cluster. The credential proves who you are and which cluster you +can connect to; it contains no graph or action permissions. The cluster's +applied [Cedar policy](../operations/policy.md) supplies those permissions. +For the example's stored queries, it must permit `invoke_query` plus `read` +or `change`. Ad-hoc query and mutation source uses `read` or `change` +respectively. Control-plane admin or apply permission does not confer graph +permission. + +When a changed policy is applied and activated by a server restart, it governs +the next request using the same credential. Editing a source file alone has +no effect. Schema changes retain the [cluster configuration +workflow](../operations/policy.md#actions); identity credentials do not bypass +its ownership or permission checks. -`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` +Normal issuance takes neither `--graph` nor `--actions`; choose a graph on the +operation that needs it. `--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. +`--json` and human output contain metadata only, never the signed credential. + +## Discover graphs + +With a cached identity credential, `graphs list` from the managed folder +shows every graph ID and display name in the server's applied inventory, +including graphs that failed to open. It does not reveal availability, +storage locations, schema, stored queries, or graph contents. A display name +currently equals its graph ID. Listing a graph does not grant access to it; +an absent policy or unknown policy actor still denies protected operations. + +For an explicitly addressed server whose configured credential is an identity +credential, select this minimal inventory with `--discovery`: + +```bash +omnigraph graphs list --server prod --discovery --json +``` + +Without `--discovery`, an explicit server keeps the existing graph-metadata +listing and requires `graph_list` policy permission. The CLI does not guess +the credential type from a token's appearance. A server that lacks discovery +support, or a static or legacy credential, refuses this route without falling +back to another inventory. + +## Cached access and routing 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 +OS-keychain entry, replacing that cluster's previous entry. The versioned +cache binds the fixed data endpoint, cluster incarnation, expiry, key ID, and +actor to the identity credential. 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`, `mutate`, and `graphs list` read `.omnigraph/context` only in +the current directory; `query` and `mutate` require `--graph`. After +`cluster token --config DIR`, run these 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 and has a 30-second total deadline, including connection establishment and reading the response, @@ -48,16 +79,18 @@ with at most 10 seconds to connect. Responses are limited to 8 MiB. Requests are not automatically retried. If a mutation times out, it may still have committed: check the graph's state before deciding whether to submit it again. -Missing, malformed, expired, or insufficient cached authority refuses before -a request. An explicit `--server`, `--profile`, `--store`, or `--cluster` -selects ordinary addressing and follows that command's existing support +Missing, malformed, or expired cached credentials refuse before a request; +the server decides policy permissions. An explicit `--server`, `--profile`, +`--store`, or `--cluster` selects ordinary addressing and follows that +command's existing support rules, even in a managed folder or beside malformed context. Other data commands, aliases, and storage maintenance also keep their ordinary behavior; this does not give them managed credentials. Explicit `--as` alone is not a target and remains prohibited on managed requests. -Implicit `query`/`mutate` refuses with `managed_target_ambiguous` when valid -folder context competes with `OMNIGRAPH_PROFILE` or an operator default server +Implicit `query`, `mutate`, or `graphs list` refuses with +`managed_target_ambiguous` when valid folder context competes with +`OMNIGRAPH_PROFILE` or an operator default server or store. No credential is read and neither destination is contacted. Choose the ordinary target explicitly, or use `--direct` to select ordinary ambient resolution. To use the managed folder, unset the environment profile and @@ -70,11 +103,39 @@ For example, this keeps using staging from a folder bound to production: omnigraph query find_person --profile staging --graph knowledge --json ``` -Legacy token settings never supply managed authority. Global `--direct` +Ordinary token settings never supply managed authority. Global `--direct` continues to select ordinary addressing and credentials, including when the context is malformed. Existing `cluster --direct` remains valid. Without managed context, existing data commands retain their behavior. +## Legacy restricted credentials + +To request the older restricted profile explicitly, supply both a graph and +the exact action ceiling: + +```bash +omnigraph cluster token --graph knowledge --actions read,change,invoke_query --ttl 1h +``` + +Accepted actions are `read`, `export`, `change`, `branch_create`, +`branch_delete`, `branch_merge`, `invoke_query`, and `graph_list`. Duplicate +actions, wildcards, `admin`, `config_manage`, and `schema_apply` refuse. Both +the signed ceiling and applied Cedar policy must allow an operation. The CLI +rejects operations outside the cached ceiling before a request. Managed +`graphs list` requires an identity credential; it never upgrades a restricted +credential to gain discovery access. The legacy HTTP metadata catalog retains +its graph filtering. + +Existing restricted caches keep their version and exact grants. An explicit +`--actions` request is never ignored or silently changed to an identity +credential. An unsupported issuance profile refuses and preserves the previous +cache. Running normal `cluster token` is an explicit request to replace it +with the identity profile, subject to the issuer's admission check. Older CLI +versions that only understand restricted credentials cannot use that cache; +they must be upgraded or use explicit restricted issuance. + +## Clear a credential + `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: diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 738ac335f..d6ac8bb8e 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -60,7 +60,7 @@ server resolves the actor from the bearer token. Drop it, or use `--store ` | `rebuild-full-text-indexes` | Replace full-text indexes on one branch | direct | | `repair` | Preview or publish classified storage drift | direct | | `cleanup` | Delete old versions under an explicit retention policy | direct | -| `graphs list` | List graphs on a server | served | +| `graphs list` | List graph metadata or minimal identity discovery | served | | `queries list/validate` | Inspect or validate a cluster query registry | cluster | | `cluster validate/plan/apply/...` | Operate declarative cluster state | cluster config or managed context | | `policy validate/test/explain` | Validate or evaluate applied policy | cluster | @@ -99,10 +99,9 @@ pretty and the `rows` array compact, verbatim. ### Machine-readable read and write positions -When the read snapshot has an effective graph head, `omnigraph query --json` -returns its `graph_commit_id` in the complete read envelope. The id and rows -come from the same pinned snapshot; use that id when a later mutation must be -conditional on the state that was read. +`query --json` returns `graph_commit_id` when its read snapshot has a graph +head. The id and rows share one pinned snapshot; use that id for a later +conditional mutation. Successful `mutate --json`, `load --json`, and compatibility `ingest --json` responses include `commit`, the exact commit published by @@ -111,6 +110,10 @@ that attempt. It contains `graph_commit_id`, optional `graph_branch`, `actor_id`, and `created_at` in Unix microseconds. A successful mutation that changes no entities returns `"commit": null`. +`--json` and read commands' `--format json` preserve a graph server's complete +structured error on stdout (for example, `"code": "forbidden"`) and exit 1. +Malformed responses remain diagnostics. Conditional mismatches retain exit 4. + ### Conditional mutations ```bash @@ -326,9 +329,9 @@ 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. +`cluster token` caches an identity credential; applied Cedar policy supplies +permissions. See [managed data access](managed-data.md) for graph discovery, +routing, offline access, expiry, clearing and explicit restricted credentials. ## Confirmation rules diff --git a/docs/user/operations/policy.md b/docs/user/operations/policy.md index 4d6d2f164..7aaaab21c 100644 --- a/docs/user/operations/policy.md +++ b/docs/user/operations/policy.md @@ -1,6 +1,7 @@ # Authorization and actors -OmniGraph uses Cedar policy bundles to authorize graph and server actions. +OmniGraph uses Cedar policy bundles to authorize graph, server, and cluster +configuration actions. Policies are declared in `cluster.yaml`, applied with the cluster, and loaded when the server starts. @@ -20,7 +21,18 @@ Graph-scoped actions: | `invoke_query` | Entry to a stored query | | `admin` | Reserved graph administration | -`graph_list` is server-scoped and controls `GET /graphs`. +`graph_list` is server-scoped and controls the metadata catalog at +`GET /graphs`. It does not control the minimal identity-authenticated +`GET /graphs/discovery` route: every valid cluster identity can discover all +applied graph IDs and display names, without gaining permission to read their +schema or contents. See [signed data credentials](server.md#signed-data-credentials). + +`config_manage` is cluster-scoped. It authorizes configuration changes through +the identity-authorized cluster API, including policy membership, stored +queries, graph creation, and a new graph's initial schema. Changing an existing +graph's schema requires that graph's `schema_apply` permission; +reading its remote schema or migration preview requires `read` on `main`. +The reserved graph `admin` action does not grant cluster management. A stored mutation requires both `invoke_query` and `change`. A stored read requires `invoke_query` and `read`. @@ -59,6 +71,36 @@ rules: actions: [invoke_query] ``` +A cluster-bound bundle can contain separate configuration and inventory rules: + +```yaml +version: 1 +groups: + owners: [principal:alice] +rules: + - id: owners-manage-config + allow: + actors: { group: owners } + actions: [config_manage] + - id: owners-list-graphs + allow: + actors: { group: owners } + actions: [graph_list] +``` + +`config_manage` uses the Cedar `Cluster::"root"` resource; `graph_list` uses +`Server::"root"`. Put them in separate rules and do not give either a branch +scope. Bind this file with `applies_to: [cluster]` as above. + +For an identity-authorized apply, the **current applied policy** authorizes all +planned effects before any graph or configuration effect. A proposed policy +cannot give its author permission to install itself. A saved plan does not +transfer its author's permissions: another caller must pass the current policy +checks. Explicit first initialization can install a declared initial management +policy under an exact bootstrap capability; missing policy on an existing +cluster is an error, not permission to bootstrap again. Existing storage-holder +cluster APIs keep their explicit trust boundary. + Graph rules may use `branch_scope` for a source branch or `target_branch_scope` for a destination branch. Values are `any`, `protected`, or `unprotected`; a rule may not set both. Server actions and graph-wide @@ -83,6 +125,10 @@ Run `cluster apply` and restart servers after changing a policy source. For HTTP requests, the server maps the bearer token to an actor. Headers, query parameters, and request bodies cannot override that identity. +Signed credentials use `principal:`; groups and +permissions come from applied policy. An identity credential contains no +graph/action grants. Legacy restricted credentials retain an additional +ceiling; they cannot override a policy denial. For direct CLI writes, actor resolution is: @@ -103,6 +149,8 @@ omnigraph commit show --store ./graph.omni --json ## Server startup modes +For static token authentication: + | Tokens | Policy | Startup and authorization | |---|---|---| | none | none | Requires explicit `--unauthenticated`; otherwise startup fails | @@ -113,6 +161,11 @@ omnigraph commit show --store ./graph.omni --json `GET /graphs` is denied unless a `cluster`-scoped policy grants `graph_list`, including when graph policies exist. +Signed-token trust also enables authenticated startup. Signed credentials +require an explicit policy permit for protected operations even when no +static tokens are configured. Only the minimal identity discovery route is +independent of policy membership; it exposes existence, not graph access. + Policy is enforced for graph writes inside the engine as well as at the HTTP boundary. This keeps direct and embedded writers subject to the same action checks when a policy engine is installed. Per-entity and per-property diff --git a/docs/user/operations/server.md b/docs/user/operations/server.md index 7e0aeac04..dbc3109ed 100644 --- a/docs/user/operations/server.md +++ b/docs/user/operations/server.md @@ -74,7 +74,7 @@ Clients cannot claim another actor. See [Authorization and actors](policy.md). 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 +on a trusted development network. Static tokens without a policy allow only the `read` action. Stored-query invocation, export, graph listing, writes, and other actions remain denied. @@ -96,20 +96,39 @@ 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. +Signed credentials use the actor `principal:`. A caller +cannot change its actor through request headers or JSON. The server accepts +two explicit profiles: + +- **Identity credentials (version 2)** bind the principal to the cluster and + contain no permissions. Applied Cedar policy decides graph operations; + missing policy or an unknown policy actor denies protected access. +- **Legacy restricted credentials (version 1)** additionally limit access to + their exact graph/action grants. Both the grant and applied policy must + allow the request. These credentials cannot grant `schema_apply`, + `config_manage`, or `admin`. + +Every valid identity credential can call `GET /graphs/discovery` for graph IDs +and display names from the server's applied inventory, including quarantined +graphs. Display names currently equal graph IDs. This route returns no storage +locations, availability, schema, query definitions, or graph data, and does not +require policy membership. It accepts neither static nor restricted +credentials. `GET /graphs` remains a separate metadata catalog requiring +`graph_list` policy permission; restricted credentials also filter it to +graphs with a signed `graph_list` grant. Discovery does not make an unavailable +server reachable or grant access to a listed graph. + +See [managed data access](../cli/managed-data.md) for issuance and CLI discovery. 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`. +body. An applied policy change takes effect on the next request after server +activation, using the same identity credential. Schema changes still use +`cluster apply` and its [current-policy authorization](policy.md#actions); +the identity credential supplies no permission or ownership bypass. Static credentials can coexist for operator recovery. An exact configured static credential keeps its existing authority, including credentials with @@ -124,7 +143,8 @@ seconds after its final issuance before removing it with another restart. |---|---| | `GET /healthz` | Process health | | `GET /openapi.json` | Runtime copy of the OpenAPI document | -| `GET /graphs` | List served graphs; requires `graph_list` policy | +| `GET /graphs` | Graph metadata catalog; requires `graph_list` policy | +| `GET /graphs/discovery` | Graph IDs and display names only; requires an identity credential | | `/graphs/{id}/query`, `/mutate` | Run inline GQ source | | `/graphs/{id}/mutate/if-graph-commit` | Run an inline conditional mutation | | `/graphs/{id}/queries` | List and invoke stored queries, including conditional mutations | diff --git a/openapi.json b/openapi.json index df973c301..b41d9577d 100644 --- a/openapi.json +++ b/openapi.json @@ -67,6 +67,51 @@ ] } }, + "/graphs/discovery": { + "get": { + "tags": [ + "management" + ], + "operationId": "discoverGraphs", + "responses": { + "200": { + "description": "Authenticated minimal graph inventory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphDiscoveryResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Identity credential required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, "/graphs/{graph_id}/blob": { "get": { "tags": [ @@ -3542,7 +3587,7 @@ "health" ], "summary": "Readiness witness (RFC 0049).", - "description": "Unauthenticated, and therefore minimal: it reports whether this replica\nis serving or draining, the applied `config_digest` it booted from, the\nledger revision and CAS it read, and how many graphs it serves and does\nnot serve. Graph ids are topology and stay behind `GET /graphs`. Answers\n503 once shutdown has begun; `/healthz` stays 200 while the process is\nalive.", + "description": "Unauthenticated, and therefore minimal: it reports whether this replica\nis serving or draining, the applied `config_digest` it booted from, the\nledger revision and CAS it read, and how many graphs it serves and does\nnot serve. Graph ids stay behind authenticated catalog endpoints. Answers\n503 once shutdown has begun; `/healthz` stays 200 while the process is\nalive.", "operationId": "readiness", "responses": { "200": { @@ -4689,9 +4734,43 @@ } } }, + "GraphDiscoveryEntry": { + "type": "object", + "description": "A graph's existence, without storage, schema, data, or serving metadata.", + "required": [ + "graph_id", + "display_name" + ], + "properties": { + "display_name": { + "type": "string", + "description": "Currently the graph identifier; no separate display name is configured." + }, + "graph_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "GraphDiscoveryResponse": { + "type": "object", + "description": "Authenticated minimal inventory from `GET /graphs/discovery`.", + "required": [ + "graphs" + ], + "properties": { + "graphs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphDiscoveryEntry" + } + } + }, + "additionalProperties": false + }, "GraphInfo": { "type": "object", - "description": "One entry in the response from `GET /graphs`. Cluster operators\nconsume this list to discover which graphs the server is currently\nserving. The shape is intentionally minimal — `graph_id` and `uri`\nare the only fields a routing client needs.", + "description": "One entry in the response from `GET /graphs`. Cluster operators\nconsume this list to discover which graphs the server is currently\nserving. This legacy metadata includes the storage `uri`; identity-only\nexistence discovery uses [`GraphDiscoveryEntry`] instead.", "required": [ "graph_id", "uri"