From 789325b6dbd60d64963e4e13415f940ff6e6f950 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 15 Jul 2026 20:13:00 +0200 Subject: [PATCH] test(authz): pin the editor role-floor on group pubsub + queue write caps Mirrors group_kv_caps_resolve_by_role_up_the_chain for the two other group write capabilities: a Viewer is denied GroupPubsubPublish and GroupQueueEnqueue, an Editor is allowed, and an outsider is denied. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/authz.rs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 45b7b1c..a125b5d 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -1382,6 +1382,64 @@ mod tests { ); } + /// §11.6 D2/D3: shared-topic PUBLISH and shared-queue ENQUEUE are writes, so + /// they require editor+ on the owning group. The three older group data caps + /// (KV/docs/files) have this test; these two — the newest, and the ones whose + /// blast radius is an entire subtree's handlers — did not. A `role_satisfies` + /// regression that let a Viewer publish/enqueue would go uncaught otherwise. + #[tokio::test] + async fn group_pubsub_and_queue_write_caps_require_editor() { + let repo = InMemoryAuthzRepo::default(); + let root = GroupId::new(); + let team = GroupId::new(); + repo.add_group(root, None).await; + repo.add_group(team, Some(root)).await; + + // A Viewer at root is denied BOTH writes on a descendant group. + let viewer = principal(InstanceRole::Member); + repo.grant_group(viewer.user_id, root, AppRole::Viewer) + .await; + for cap in [ + Capability::GroupPubsubPublish(team), + Capability::GroupQueueEnqueue(team), + ] { + assert_eq!( + can(&repo, &viewer, cap).await.unwrap(), + Decision::Deny, + "a Viewer must not publish/enqueue into a shared group collection" + ); + } + + // An Editor at root is allowed both (resolved up the chain). + let editor = principal(InstanceRole::Member); + repo.grant_group(editor.user_id, root, AppRole::Editor) + .await; + for cap in [ + Capability::GroupPubsubPublish(team), + Capability::GroupQueueEnqueue(team), + ] { + assert!( + can(&repo, &editor, cap).await.unwrap().is_allow(), + "an Editor on the owning group's ancestor must be allowed" + ); + } + + // An unrelated member gets neither. + let outsider = principal(InstanceRole::Member); + assert_eq!( + can(&repo, &outsider, Capability::GroupPubsubPublish(team)) + .await + .unwrap(), + Decision::Deny + ); + assert_eq!( + can(&repo, &outsider, Capability::GroupQueueEnqueue(team)) + .await + .unwrap(), + Decision::Deny + ); + } + #[tokio::test] async fn group_docs_caps_resolve_by_role_up_the_chain() { // §11.6 docs: same trust shape as shared KV — read is viewer+, write is