From 6508e3e1f822e24f91e8e6885046d7e260bc8562 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 6 Oct 2026 22:44:43 +0200 Subject: [PATCH] fix(cluster): authorize deployment metadata at cluster scope --- ...deployment-metadata-authorization.fixed.md | 3 + changelog.d/release.json | 4 +- .../src/deployment/execution.rs | 23 ++- .../omnigraph-cluster/src/deployment/tests.rs | 139 ++++++++++++++++++ docs/dev/control-plane.md | 5 +- docs/user/clusters/index.md | 6 +- 6 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 changelog.d/deployment-metadata-authorization.fixed.md diff --git a/changelog.d/deployment-metadata-authorization.fixed.md b/changelog.d/deployment-metadata-authorization.fixed.md new file mode 100644 index 000000000..d0e56291f --- /dev/null +++ b/changelog.d/deployment-metadata-authorization.fixed.md @@ -0,0 +1,3 @@ +- Cluster operators can inspect deployment status and receipts and converge unchanged declarations without read permission on unrelated graphs. Cluster `config_manage`, graph-data read authorization, and schema-effect `schema_apply` checks remain enforced. See [cluster operations][deployment-metadata-auth]. + +[deployment-metadata-auth]: ../docs/user/clusters/index.md diff --git a/changelog.d/release.json b/changelog.d/release.json index 788c18fb1..7079c1508 100644 --- a/changelog.d/release.json +++ b/changelog.d/release.json @@ -1,5 +1,5 @@ { - "version": "v0.12.0", - "base": "refs/tags/v0.11.0", + "version": "v0.12.1", + "base": "refs/tags/v0.12.0", "legacy": null } diff --git a/crates/omnigraph-cluster/src/deployment/execution.rs b/crates/omnigraph-cluster/src/deployment/execution.rs index 6f84a407e..5989497e4 100644 --- a/crates/omnigraph-cluster/src/deployment/execution.rs +++ b/crates/omnigraph-cluster/src/deployment/execution.rs @@ -29,7 +29,6 @@ async fn policies( store: &ClusterStore, state: &ClusterState, caller: &DeploymentCaller, - manage: bool, ) -> Result { caller.authority()?; let policies = match caller { @@ -38,14 +37,10 @@ async fn policies( } DeploymentCaller::AuthenticatedIdentity(identity) => { let policies = AppliedPolicies::load(store, state).await?; - if manage { - policies.check_cluster(identity.actor())?; - } - for address in state.applied_revision.resources.keys() { - if let ResourceKind::Graph(graph) = resource_kind(address) { - policies.check_graph(identity.actor(), &graph, PolicyAction::Read)?; - } - } + // ConfigManage covers ledger and receipt metadata, not graph data. + // Schema effects still enforce graph SchemaApply during preflight + // and recovery; ordinary graph reads retain their own policy gate. + policies.check_cluster(identity.actor())?; policies } }; @@ -125,7 +120,7 @@ async fn deployment_policies( return AppliedPolicies::load_optional(store, state).await; } } - policies(store, state, caller, true).await + policies(store, state, caller).await } // Before first convergence there is no applied management policy. Only the @@ -160,7 +155,7 @@ async fn lookup_policies( return AppliedPolicies::load_optional(store, state).await; } } - policies(store, state, caller, true).await + policies(store, state, caller).await } fn graph_ids(state: &ClusterState) -> Vec { @@ -322,7 +317,7 @@ pub async fn upgrade_deployment_ledger( } // Refuse unauthorized conversion before acquiring durable admission; a // denied caller must not strand a lock. Recheck current policy under it. - policies(&store, &before, caller, true).await?; + policies(&store, &before, caller).await?; let mut observations = store.observations(); let mut guard = store .acquire_lock("upgrade_ledger", &mut observations) @@ -339,7 +334,7 @@ pub async fn upgrade_deployment_ledger( "ledger changed during conversion; inspect original root", )); } - policies(&store, &state, caller, true).await?; + policies(&store, &state, caller).await?; authorization::refuse_pending_recovery(&store).await?; serve::read_snapshot_for_ledger_upgrade(&store) .await @@ -1224,7 +1219,7 @@ pub async fn deployment_affected_graphs( let store = ClusterStore::for_storage_root(admission.canonical_root())?; let (state, _) = read_existing(&store).await?; require_v2(&state)?; - policies(&store, &state, caller, true).await?; + policies(&store, &state, caller).await?; if state.outstanding.is_some() { return Err(refusal( "cluster_deployment_outstanding", diff --git a/crates/omnigraph-cluster/src/deployment/tests.rs b/crates/omnigraph-cluster/src/deployment/tests.rs index 7ffc04f1a..09fa6ed8b 100644 --- a/crates/omnigraph-cluster/src/deployment/tests.rs +++ b/crates/omnigraph-cluster/src/deployment/tests.rs @@ -308,6 +308,145 @@ fn identity(actor: &str) -> DeploymentCaller { DeploymentCaller::AuthenticatedIdentity(IdentityAuthorization::authenticated(actor).unwrap()) } +#[tokio::test] +async fn configuration_metadata_does_not_require_unrelated_graph_read() { + let dir = crate::tests::identity_fixture(); + let root = dir.path().to_str().unwrap(); + add_second_graph(dir.path()); + let config_path = dir.path().join(CLUSTER_CONFIG_FILE); + let config = fs::read_to_string(&config_path) + .unwrap() + .replace("applies_to: [knowledge, second]", "applies_to: [knowledge]"); + fs::write(config_path, config).unwrap(); + let first = bootstrap(dir.path()).await; + unlock(root).await; + let before = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let mut versions = BTreeMap::new(); + for graph in ["knowledge", "second"] { + let db = Omnigraph::open_read_only( + dir.path() + .join(format!("graphs/{graph}.omni")) + .to_str() + .unwrap(), + ) + .await + .unwrap(); + versions.insert( + graph, + db.snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + ); + } + // Status and exact receipt lookup disclose control metadata, not rows. + let status = deployment_status(root, Some(&first.id), &identity("principal:owner")) + .await + .unwrap(); + assert!(matches!( + status.lookup, + Some(DeploymentLookup::Complete { .. }) + )); + assert!(status.lock_id.is_none()); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + let error = deployment_status(root, None, &identity("principal:reader")) + .await + .unwrap_err(); + assert_eq!(error.code, "policy_denied"); + // Removing the inventory-wide Read requirement does not grant data access. + let store = ClusterStore::for_storage_root(root).unwrap(); + let state: ClusterState = serde_json::from_slice(&before).unwrap(); + let policies = crate::authorization::AppliedPolicies::load(&store, &state) + .await + .unwrap(); + let error = policies + .check_graph( + "principal:owner", + "second", + omnigraph_policy::PolicyAction::Read, + ) + .unwrap_err(); + assert_eq!(error.code, "graph_policy_required"); + // A schema effect on the unbound sibling still refuses before publication, + // even though this caller may configure the cluster and change knowledge. + fs::write( + dir.path().join("people.pg"), + crate::tests::SCHEMA.replace("age: I32?", "age: I32?\n email: String?"), + ) + .unwrap(); + let error = apply_deployment( + dir.path(), + None, + &identity("principal:owner"), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap_err(); + assert_eq!(error.code, "graph_policy_required"); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + before + ); + assert!( + deployment_status(root, None, &owner()) + .await + .unwrap() + .lock_id + .is_none() + ); + fs::write(dir.path().join("people.pg"), crate::tests::SCHEMA).unwrap(); + let result = apply_deployment( + dir.path(), + None, + &identity("principal:collaborator"), + &BTreeMap::new(), + |_, _, _| {}, + ) + .await + .unwrap(); + let DeploymentLookup::Complete { result } = result else { + panic!("{result:?}"); + }; + assert!(result.converged); + assert!(result.graphs.is_empty()); + let after_noop = fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(); + let repeated = apply_deployment( + dir.path(), + Some(&result.id), + &identity("principal:collaborator"), + &BTreeMap::new(), + |_, _, _| panic!("Recorded invocation must not execute again"), + ) + .await + .unwrap(); + assert!(matches!(repeated, DeploymentLookup::Complete { .. })); + assert_eq!( + fs::read(dir.path().join(CLUSTER_STATE_FILE)).unwrap(), + after_noop + ); + for (graph, version) in versions { + let db = Omnigraph::open_read_only( + dir.path() + .join(format!("graphs/{graph}.omni")) + .to_str() + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + db.snapshot_of(ReadTarget::branch("main")) + .await + .unwrap() + .graph_manifest_version(), + version, + ); + } +} + #[tokio::test] async fn authenticated_deployment_checks_current_policy_before_every_effect() { let dir = crate::tests::identity_fixture(); diff --git a/docs/dev/control-plane.md b/docs/dev/control-plane.md index f66891e4a..4aa15517f 100644 --- a/docs/dev/control-plane.md +++ b/docs/dev/control-plane.md @@ -114,7 +114,10 @@ nonce is unknown. Applied result revision is distinct from server activation. `DeploymentCaller` preserves the storage-owner trust boundary and an optional actor label. Authenticated identity callers recheck current cluster -`ConfigManage`, graph `Read`, and `SchemaApply` for schema effects/recovery. +`ConfigManage` for ledger/receipt metadata and graph `SchemaApply` for schema +effects/recovery. Unrelated graph `Read` is not required to inspect deployment +metadata or converge an unchanged declaration; graph-data reads retain their +separate installed-policy checks. Original authority stays immutable; recovery records the current executor while preserving the authored engine receipt. The accepted [server runtime RFC](../rfcs/2026-09-29-server-runtime-and-online-deployment.md) diff --git a/docs/user/clusters/index.md b/docs/user/clusters/index.md index 4edf73199..dd1ac4517 100644 --- a/docs/user/clusters/index.md +++ b/docs/user/clusters/index.md @@ -41,8 +41,10 @@ of the deployment ledger version. The [configuration reference](config.md) covers storage roots, embedding providers, external Blob policy and limits. For server-owned deployment, the applied policy must grant the operator -`config_manage` at cluster scope, `read` on disclosed graphs, and `schema_apply` -on graphs whose schema changes. New graphs need suitable declared policies too. +`config_manage` at cluster scope and `schema_apply` on graphs whose schema +changes. Deployment status, receipts, and unchanged declarations do not require +`read` on unrelated graphs. Reading graph data still requires that graph's +applied read permission. New graphs need suitable declared policies too. The server derives the actor from its bearer token; `--as` is for direct access. ## Bootstrap a cluster