Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/deployment-metadata-authorization.fixed.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions changelog.d/release.json
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 9 additions & 14 deletions crates/omnigraph-cluster/src/deployment/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ async fn policies(
store: &ClusterStore,
state: &ClusterState,
caller: &DeploymentCaller,
manage: bool,
) -> Result<AppliedPolicies, Diagnostic> {
caller.authority()?;
let policies = match caller {
Expand All @@ -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
}
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions crates/omnigraph-cluster/src/deployment/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion docs/dev/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions docs/user/clusters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +46 to +47

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Qualify the read-permission statement for installed policies

This sentence describes a stronger rule than the server enforces. With an ordinary bearer token and no graph policy, handlers::authorize permits Read. See the existing fallback and its regression, which passed in this head's CI. Signed data credentials have a separate rule. Please say that graph reads retain their existing authorization rules and that an installed graph policy must permit the read. This avoids implying that a graph without a policy is unreadable. This is an optional wording correction, not a defect in the deployment check.

The server derives the actor from its bearer token; `--as` is for direct access.

## Bootstrap a cluster
Expand Down
Loading