feat(v1.1.8): Capability variants + scope mapping for app-users

Add AppUsersRead / AppUsersWrite / AppUsersAdmin capability variants
gating the upcoming users::* SDK and admin HTTP surface. All three
map onto existing scopes (script:read / script:write — no new scope
introduced; the seven-scope commitment is preserved). The Admin
variant gets the extra app-role gate via the per-app role chain
(app_admin+ only), mirroring how AppTopicManage / AppManageTriggers
already work.

Tests cover the viewer/editor/app_admin chain end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-05 23:12:23 +02:00
parent 5cbb6ca427
commit 6ef9f436c1

View File

@@ -115,6 +115,22 @@ pub enum Capability {
/// weight (it opens an internal pub/sub topic to outside SSE
/// subscribers). Granted to `app_admin`+.
AppTopicManage(AppId),
/// Read app-user records (v1.1.8 `users::*`) — `get`,
/// `find_by_email`, `list`, `verify`, `has_role`. Same trust shape
/// as KV/docs/files read — granted to `viewer`+, maps to
/// `script:read` on API keys. Honors the seven-scope commitment.
AppUsersRead(AppId),
/// Write app-user records (v1.1.8 `users::*`) — `create`, `update`,
/// `delete`, role mutations, login/logout, password reset, email
/// verification, invitation acceptance. Granted to `editor`+, maps
/// to `script:write` on API keys.
AppUsersWrite(AppId),
/// Admin-tier app-user actions (v1.1.8) — issuing invitations,
/// admin-mediated reset-password / revoke-sessions HTTP endpoints.
/// Maps to `script:write` on API keys (no new scope per the
/// seven-scope commitment); the additional gate vs `Write` lives in
/// the per-app role chain (`app_admin`+ only).
AppUsersAdmin(AppId),
}
impl Capability {
@@ -145,7 +161,10 @@ impl Capability {
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
| Self::AppTopicManage(id) => Some(id),
| Self::AppTopicManage(id)
| Self::AppUsersRead(id)
| Self::AppUsersWrite(id)
| Self::AppUsersAdmin(id) => Some(id),
}
}
@@ -164,7 +183,8 @@ impl Capability {
| Self::AppKvRead(_)
| Self::AppDocsRead(_)
| Self::AppFilesRead(_)
| Self::AppSecretsRead(_) => Scope::ScriptRead,
| Self::AppSecretsRead(_)
| Self::AppUsersRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -172,7 +192,9 @@ impl Capability {
| Self::AppFilesWrite(_)
| Self::AppPubsubPublish(_)
| Self::AppSecretsWrite(_)
| Self::AppEmailSend(_) => Scope::ScriptWrite,
| Self::AppEmailSend(_)
| Self::AppUsersWrite(_)
| Self::AppUsersAdmin(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_)
@@ -324,6 +346,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppDocsRead(_)
| Capability::AppFilesRead(_)
| Capability::AppSecretsRead(_)
| Capability::AppUsersRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -337,6 +360,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppPubsubPublish(_)
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
| Capability::AppUsersWrite(_)
);
let in_app_admin = in_editor
|| matches!(
@@ -346,6 +370,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppManageTriggers(_)
| Capability::AppDeadLetterManage(_)
| Capability::AppTopicManage(_)
| Capability::AppUsersAdmin(_)
);
match role {
AppRole::Viewer => in_viewer,
@@ -718,6 +743,66 @@ mod tests {
.is_allow());
}
#[tokio::test]
async fn app_users_admin_requires_app_admin_role() {
let repo = InMemoryAuthzRepo::default();
let app = AppId::new();
// Per the seven-scope commitment, AppUsersAdmin maps to
// script:write (no new scope) — but the per-app role chain
// still gates it at app_admin+.
assert_eq!(
Capability::AppUsersAdmin(app).required_scope(),
Scope::ScriptWrite
);
// Member with only Editor role cannot administer users.
let p = principal(InstanceRole::Member);
repo.grant(p.user_id, app, AppRole::Editor).await;
assert_eq!(
can(&repo, &p, Capability::AppUsersAdmin(app)).await.unwrap(),
Decision::Deny,
);
// App-admin role can.
let admin = principal(InstanceRole::Member);
repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
assert!(can(&repo, &admin, Capability::AppUsersAdmin(app))
.await
.unwrap()
.is_allow());
}
#[tokio::test]
async fn app_users_read_and_write_follow_viewer_editor_chain() {
let repo = InMemoryAuthzRepo::default();
let app = AppId::new();
let viewer = principal(InstanceRole::Member);
repo.grant(viewer.user_id, app, AppRole::Viewer).await;
assert!(can(&repo, &viewer, Capability::AppUsersRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &viewer, Capability::AppUsersWrite(app))
.await
.unwrap(),
Decision::Deny,
);
let editor = principal(InstanceRole::Member);
repo.grant(editor.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &editor, Capability::AppUsersWrite(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &editor, Capability::AppUsersAdmin(app))
.await
.unwrap(),
Decision::Deny,
);
}
#[test]
fn capability_app_id_extraction() {
let app = AppId::new();