feat(authz): GroupDocsRead/GroupDocsWrite capabilities (§11.6 docs C2)

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 07:35:37 +02:00
parent 5d1d4b3ff6
commit 61352c7e5e

View File

@@ -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();