From 61352c7e5e9d3347929d1a1d2f5dc20452c8741a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 07:35:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(authz):=20GroupDocsRead/GroupDocsWrite=20c?= =?UTF-8?q?apabilities=20(=C2=A711.6=20docs=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-scope authz refinement for group docs collections — same trust shape as the GroupKv* caps: GroupDocsRead = viewer+, GroupDocsWrite = editor+, resolved via the group ancestor walk. Wired into app_id() (None), required_scope() (ScriptRead/ScriptWrite), the Member→group_member_grants route, and group_role_satisfies. Unit test mirrors the group-kv cap test. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/authz.rs | 65 +++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index fccc67b..500d9cc 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -154,6 +154,12 @@ pub enum Capability { /// for an anonymous principal — mutation of shared data always requires an /// authenticated caller (enforced at the service layer, not by skipping). GroupKvWrite(GroupId), + /// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning + /// group; same reads-open trust model as `GroupKvRead`. + GroupDocsRead(GroupId), + /// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning + /// group; fails closed for an anonymous principal, like `GroupKvWrite`. + GroupDocsWrite(GroupId), /// Send an outbound email from a script in this app (v1.1.7). Maps /// to `script:write` on API keys (sending mail is an outbound /// side-effect like an HTTP request). Granted to `editor`+. @@ -222,7 +228,9 @@ impl Capability { | Self::GroupScriptsRead(_) | Self::GroupScriptsWrite(_) | Self::GroupKvRead(_) - | Self::GroupKvWrite(_) => None, + | Self::GroupKvWrite(_) + | Self::GroupDocsRead(_) + | Self::GroupDocsWrite(_) => None, Self::AppRead(id) | Self::AppWriteScript(id) | Self::AppWriteRoute(id) @@ -275,7 +283,8 @@ impl Capability { | Self::GroupRead(_) | Self::GroupVarsRead(_) | Self::GroupScriptsRead(_) - | Self::GroupKvRead(_) => Scope::ScriptRead, + | Self::GroupKvRead(_) + | Self::GroupDocsRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) @@ -295,6 +304,7 @@ impl Capability { | Self::GroupSecretsWrite(_) | Self::GroupScriptsWrite(_) | Self::GroupKvWrite(_) + | Self::GroupDocsWrite(_) | Self::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, @@ -512,7 +522,9 @@ async fn role_grants( | Capability::GroupScriptsRead(g) | Capability::GroupScriptsWrite(g) | Capability::GroupKvRead(g) - | Capability::GroupKvWrite(g) => { + | Capability::GroupKvWrite(g) + | Capability::GroupDocsRead(g) + | Capability::GroupDocsWrite(g) => { group_member_grants(repo, principal.user_id, cap, g).await } // Creating a root-level group is an instance act — members @@ -576,13 +588,15 @@ const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool { Capability::GroupRead(_) | Capability::GroupVarsRead(_) | Capability::GroupScriptsRead(_) - | Capability::GroupKvRead(_) => true, + | Capability::GroupKvRead(_) + | Capability::GroupDocsRead(_) => true, // editor+ writes config vars/secrets/scripts and shared KV. Capability::GroupWrite(_) | Capability::GroupVarsWrite(_) | Capability::GroupSecretsWrite(_) | Capability::GroupScriptsWrite(_) - | Capability::GroupKvWrite(_) => { + | Capability::GroupKvWrite(_) + | Capability::GroupDocsWrite(_) => { matches!(role, AppRole::Editor | AppRole::AppAdmin) } // group_admin manages the group + reads secret VALUES (the @@ -1322,6 +1336,47 @@ mod tests { ); } + #[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 + // editor+, resolved via the group ancestor walk. + 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; + + let viewer = principal(InstanceRole::Member); + repo.grant_group(viewer.user_id, root, AppRole::Viewer) + .await; + assert!(can(&repo, &viewer, Capability::GroupDocsRead(team)) + .await + .unwrap() + .is_allow()); + assert_eq!( + can(&repo, &viewer, Capability::GroupDocsWrite(team)) + .await + .unwrap(), + Decision::Deny + ); + + let editor = principal(InstanceRole::Member); + repo.grant_group(editor.user_id, root, AppRole::Editor) + .await; + assert!(can(&repo, &editor, Capability::GroupDocsWrite(team)) + .await + .unwrap() + .is_allow()); + + let outsider = principal(InstanceRole::Member); + assert_eq!( + can(&repo, &outsider, Capability::GroupDocsRead(team)) + .await + .unwrap(), + Decision::Deny + ); + } + #[tokio::test] async fn admin_implicitly_manages_the_whole_group_tree() { let repo = InMemoryAuthzRepo::default();