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