From 6ef9f436c1f7daca082225443c8cfea64e57aaaf Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 5 Jun 2026 23:12:23 +0200 Subject: [PATCH] feat(v1.1.8): Capability variants + scope mapping for app-users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/manager-core/src/authz.rs | 91 ++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index edb9a64..0a30c78 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -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();