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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user