From d1de43e9194d737c50f919dc5351921d54e6a220 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 29 Jun 2026 21:34:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(authz):=20GroupKvRead/GroupKvWrite=20capab?= =?UTF-8?q?ilities=20(=C2=A711.6=20C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-scope authz refinement for group collections. Two group-scoped caps resolved via the existing group ancestor walk (effective_group_role): GroupKvRead = viewer+, GroupKvWrite = editor+. Wired into app_id() (None — group caps carry no app_id, so a bound API key is denied at the binding layer), required_scope() (ScriptRead / ScriptWrite), the Member→group_member_grants route, and group_role_satisfies. Unit test covers viewer-reads-not-writes, editor-writes, outsider-denied, bound-key-denied up the chain. The reads-open/writes-authed trust split (anonymous read bypass; anonymous write fail-closed) is enforced at the service layer in the next commit — these caps are the authenticated-principal refinement on top of the structural subtree boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/authz.rs | 93 +++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 9f27ccc..be775e6 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -142,6 +142,18 @@ pub enum Capability { /// the group; maps to `script:write` — the same tier as `AppWriteScript` /// for an app, lifted to the group owner. GroupScriptsWrite(GroupId), + /// Read a group-owned shared KV collection (§11.6). Resolved via the group + /// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The + /// reads-open trust model means a script with no principal (anonymous + /// public HTTP) bypasses this check — the structural subtree boundary + /// (the collection only resolves for apps under the owning group) is the + /// hard isolation; this cap refines access for authenticated callers. + GroupKvRead(GroupId), + /// Write a group-owned shared KV collection (§11.6). editor+ on the owning + /// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED + /// for an anonymous principal — mutation of shared data always requires an + /// authenticated caller (enforced at the service layer, not by skipping). + GroupKvWrite(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`+. @@ -208,7 +220,9 @@ impl Capability { | Self::GroupSecretsRead(_) | Self::GroupSecretsWrite(_) | Self::GroupScriptsRead(_) - | Self::GroupScriptsWrite(_) => None, + | Self::GroupScriptsWrite(_) + | Self::GroupKvRead(_) + | Self::GroupKvWrite(_) => None, Self::AppRead(id) | Self::AppWriteScript(id) | Self::AppWriteRoute(id) @@ -260,7 +274,8 @@ impl Capability { | Self::AppVarsRead(_) | Self::GroupRead(_) | Self::GroupVarsRead(_) - | Self::GroupScriptsRead(_) => Scope::ScriptRead, + | Self::GroupScriptsRead(_) + | Self::GroupKvRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) @@ -279,6 +294,7 @@ impl Capability { // the admin tier below. | Self::GroupSecretsWrite(_) | Self::GroupScriptsWrite(_) + | Self::GroupKvWrite(_) | Self::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, @@ -466,7 +482,9 @@ async fn role_grants( | Capability::GroupSecretsRead(g) | Capability::GroupSecretsWrite(g) | Capability::GroupScriptsRead(g) - | Capability::GroupScriptsWrite(g) => { + | Capability::GroupScriptsWrite(g) + | Capability::GroupKvRead(g) + | Capability::GroupKvWrite(g) => { group_member_grants(repo, principal.user_id, cap, g).await } // Creating a root-level group is an instance act — members @@ -526,15 +544,17 @@ async fn group_member_grants( /// viewer→read, editor→write, group_admin(=AppAdmin)→admin. const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool { match cap { - // viewer+ reads group metadata, config vars, and scripts. + // viewer+ reads group metadata, config vars, scripts, and shared KV. Capability::GroupRead(_) | Capability::GroupVarsRead(_) - | Capability::GroupScriptsRead(_) => true, - // editor+ writes config vars/secrets/scripts. + | Capability::GroupScriptsRead(_) + | Capability::GroupKvRead(_) => true, + // editor+ writes config vars/secrets/scripts and shared KV. Capability::GroupWrite(_) | Capability::GroupVarsWrite(_) | Capability::GroupSecretsWrite(_) - | Capability::GroupScriptsWrite(_) => { + | Capability::GroupScriptsWrite(_) + | Capability::GroupKvWrite(_) => { matches!(role, AppRole::Editor | AppRole::AppAdmin) } // group_admin manages the group + reads secret VALUES (the @@ -1215,6 +1235,65 @@ mod tests { ); } + #[tokio::test] + async fn group_kv_caps_resolve_by_role_up_the_chain() { + // §11.6: shared-KV read is viewer+, write is editor+, both resolved via + // the group ancestor walk; an outsider gets nothing. + 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 can READ a descendant group's shared KV but not write. + let viewer = principal(InstanceRole::Member); + repo.grant_group(viewer.user_id, root, AppRole::Viewer) + .await; + assert!(can(&repo, &viewer, Capability::GroupKvRead(team)) + .await + .unwrap() + .is_allow()); + assert_eq!( + can(&repo, &viewer, Capability::GroupKvWrite(team)) + .await + .unwrap(), + Decision::Deny + ); + + // An editor at root can WRITE it. + let editor = principal(InstanceRole::Member); + repo.grant_group(editor.user_id, root, AppRole::Editor) + .await; + assert!(can(&repo, &editor, Capability::GroupKvWrite(team)) + .await + .unwrap() + .is_allow()); + + // An unrelated member gets neither. + let outsider = principal(InstanceRole::Member); + assert_eq!( + can(&repo, &outsider, Capability::GroupKvRead(team)) + .await + .unwrap(), + Decision::Deny + ); + + // Group caps carry no app_id, so a bound key is denied at the binding + // layer regardless of role. + let bound = Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: Some(vec![Scope::ScriptWrite]), + app_binding: Some(AppId::new()), + }; + assert_eq!( + can(&repo, &bound, Capability::GroupKvWrite(team)) + .await + .unwrap(), + Decision::Deny + ); + } + #[tokio::test] async fn admin_implicitly_manages_the_whole_group_tree() { let repo = InMemoryAuthzRepo::default();