feat(authz): GroupKvRead/GroupKvWrite capabilities (§11.6 C2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,18 @@ pub enum Capability {
|
|||||||
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||||
/// for an app, lifted to the group owner.
|
/// for an app, lifted to the group owner.
|
||||||
GroupScriptsWrite(GroupId),
|
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
|
/// 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`+.
|
||||||
@@ -208,7 +220,9 @@ impl Capability {
|
|||||||
| Self::GroupSecretsRead(_)
|
| Self::GroupSecretsRead(_)
|
||||||
| Self::GroupSecretsWrite(_)
|
| Self::GroupSecretsWrite(_)
|
||||||
| Self::GroupScriptsRead(_)
|
| Self::GroupScriptsRead(_)
|
||||||
| Self::GroupScriptsWrite(_) => None,
|
| Self::GroupScriptsWrite(_)
|
||||||
|
| Self::GroupKvRead(_)
|
||||||
|
| Self::GroupKvWrite(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -260,7 +274,8 @@ impl Capability {
|
|||||||
| Self::AppVarsRead(_)
|
| Self::AppVarsRead(_)
|
||||||
| Self::GroupRead(_)
|
| Self::GroupRead(_)
|
||||||
| Self::GroupVarsRead(_)
|
| Self::GroupVarsRead(_)
|
||||||
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
|
| Self::GroupScriptsRead(_)
|
||||||
|
| Self::GroupKvRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -279,6 +294,7 @@ impl Capability {
|
|||||||
// the admin tier below.
|
// the admin tier below.
|
||||||
| Self::GroupSecretsWrite(_)
|
| Self::GroupSecretsWrite(_)
|
||||||
| Self::GroupScriptsWrite(_)
|
| Self::GroupScriptsWrite(_)
|
||||||
|
| Self::GroupKvWrite(_)
|
||||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
@@ -466,7 +482,9 @@ async fn role_grants(
|
|||||||
| Capability::GroupSecretsRead(g)
|
| Capability::GroupSecretsRead(g)
|
||||||
| Capability::GroupSecretsWrite(g)
|
| Capability::GroupSecretsWrite(g)
|
||||||
| Capability::GroupScriptsRead(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
|
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
|
||||||
@@ -526,15 +544,17 @@ async fn group_member_grants(
|
|||||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
match cap {
|
match cap {
|
||||||
// viewer+ reads group metadata, config vars, and scripts.
|
// viewer+ reads group metadata, config vars, scripts, and shared KV.
|
||||||
Capability::GroupRead(_)
|
Capability::GroupRead(_)
|
||||||
| Capability::GroupVarsRead(_)
|
| Capability::GroupVarsRead(_)
|
||||||
| Capability::GroupScriptsRead(_) => true,
|
| Capability::GroupScriptsRead(_)
|
||||||
// editor+ writes config vars/secrets/scripts.
|
| Capability::GroupKvRead(_) => true,
|
||||||
|
// 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(_) => {
|
||||||
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
|
||||||
@@ -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]
|
#[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