feat(authz): GroupFilesRead/GroupFilesWrite capabilities (§11.6 files C2)
Clone of the GroupKv*/GroupDocs* arms at all five wiring sites (Capability variants, app_id()⇒None, required_scope()⇒ScriptRead/Write, the group_member_grants route, group_role_satisfies: Read=viewer+, Write=editor+). Unit test mirrors group_docs_caps_resolve_by_role_up_the_chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,12 @@ pub enum Capability {
|
|||||||
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
|
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
|
||||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||||
GroupDocsWrite(GroupId),
|
GroupDocsWrite(GroupId),
|
||||||
|
/// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
|
||||||
|
/// group; same reads-open trust model as `GroupKvRead`.
|
||||||
|
GroupFilesRead(GroupId),
|
||||||
|
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
|
||||||
|
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||||
|
GroupFilesWrite(GroupId),
|
||||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
/// 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
|
/// to `script:write` on API keys (sending mail is an outbound
|
||||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||||
@@ -230,7 +236,9 @@ impl Capability {
|
|||||||
| Self::GroupKvRead(_)
|
| Self::GroupKvRead(_)
|
||||||
| Self::GroupKvWrite(_)
|
| Self::GroupKvWrite(_)
|
||||||
| Self::GroupDocsRead(_)
|
| Self::GroupDocsRead(_)
|
||||||
| Self::GroupDocsWrite(_) => None,
|
| Self::GroupDocsWrite(_)
|
||||||
|
| Self::GroupFilesRead(_)
|
||||||
|
| Self::GroupFilesWrite(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -284,7 +292,8 @@ impl Capability {
|
|||||||
| Self::GroupVarsRead(_)
|
| Self::GroupVarsRead(_)
|
||||||
| Self::GroupScriptsRead(_)
|
| Self::GroupScriptsRead(_)
|
||||||
| Self::GroupKvRead(_)
|
| Self::GroupKvRead(_)
|
||||||
| Self::GroupDocsRead(_) => Scope::ScriptRead,
|
| Self::GroupDocsRead(_)
|
||||||
|
| Self::GroupFilesRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -305,6 +314,7 @@ impl Capability {
|
|||||||
| Self::GroupScriptsWrite(_)
|
| Self::GroupScriptsWrite(_)
|
||||||
| Self::GroupKvWrite(_)
|
| Self::GroupKvWrite(_)
|
||||||
| Self::GroupDocsWrite(_)
|
| Self::GroupDocsWrite(_)
|
||||||
|
| Self::GroupFilesWrite(_)
|
||||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
@@ -524,7 +534,9 @@ async fn role_grants(
|
|||||||
| Capability::GroupKvRead(g)
|
| Capability::GroupKvRead(g)
|
||||||
| Capability::GroupKvWrite(g)
|
| Capability::GroupKvWrite(g)
|
||||||
| Capability::GroupDocsRead(g)
|
| Capability::GroupDocsRead(g)
|
||||||
| Capability::GroupDocsWrite(g) => {
|
| Capability::GroupDocsWrite(g)
|
||||||
|
| Capability::GroupFilesRead(g)
|
||||||
|
| Capability::GroupFilesWrite(g) => {
|
||||||
group_member_grants(repo, principal.user_id, cap, g).await
|
group_member_grants(repo, principal.user_id, cap, g).await
|
||||||
}
|
}
|
||||||
// Creating a root-level group is an instance act — members
|
// Creating a root-level group is an instance act — members
|
||||||
@@ -589,14 +601,16 @@ const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::GroupVarsRead(_)
|
| Capability::GroupVarsRead(_)
|
||||||
| Capability::GroupScriptsRead(_)
|
| Capability::GroupScriptsRead(_)
|
||||||
| Capability::GroupKvRead(_)
|
| Capability::GroupKvRead(_)
|
||||||
| Capability::GroupDocsRead(_) => true,
|
| Capability::GroupDocsRead(_)
|
||||||
|
| Capability::GroupFilesRead(_) => true,
|
||||||
// editor+ writes config vars/secrets/scripts and shared KV.
|
// editor+ writes config vars/secrets/scripts and shared KV.
|
||||||
Capability::GroupWrite(_)
|
Capability::GroupWrite(_)
|
||||||
| Capability::GroupVarsWrite(_)
|
| Capability::GroupVarsWrite(_)
|
||||||
| Capability::GroupSecretsWrite(_)
|
| Capability::GroupSecretsWrite(_)
|
||||||
| Capability::GroupScriptsWrite(_)
|
| Capability::GroupScriptsWrite(_)
|
||||||
| Capability::GroupKvWrite(_)
|
| Capability::GroupKvWrite(_)
|
||||||
| Capability::GroupDocsWrite(_) => {
|
| Capability::GroupDocsWrite(_)
|
||||||
|
| Capability::GroupFilesWrite(_) => {
|
||||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||||
}
|
}
|
||||||
// group_admin manages the group + reads secret VALUES (the
|
// group_admin manages the group + reads secret VALUES (the
|
||||||
@@ -1377,6 +1391,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn group_files_caps_resolve_by_role_up_the_chain() {
|
||||||
|
// §11.6 files: same trust shape as shared KV/docs — 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::GroupFilesRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &viewer, Capability::GroupFilesWrite(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::GroupFilesWrite(team))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
|
||||||
|
let outsider = principal(InstanceRole::Member);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &outsider, Capability::GroupFilesRead(team))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user