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
|
/// weight (it opens an internal pub/sub topic to outside SSE
|
||||||
/// subscribers). Granted to `app_admin`+.
|
/// subscribers). Granted to `app_admin`+.
|
||||||
AppTopicManage(AppId),
|
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 {
|
impl Capability {
|
||||||
@@ -145,7 +161,10 @@ impl Capability {
|
|||||||
| Self::AppEmailSend(id)
|
| Self::AppEmailSend(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(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::AppKvRead(_)
|
||||||
| Self::AppDocsRead(_)
|
| Self::AppDocsRead(_)
|
||||||
| Self::AppFilesRead(_)
|
| Self::AppFilesRead(_)
|
||||||
| Self::AppSecretsRead(_) => Scope::ScriptRead,
|
| Self::AppSecretsRead(_)
|
||||||
|
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -172,7 +192,9 @@ impl Capability {
|
|||||||
| Self::AppFilesWrite(_)
|
| Self::AppFilesWrite(_)
|
||||||
| Self::AppPubsubPublish(_)
|
| Self::AppPubsubPublish(_)
|
||||||
| Self::AppSecretsWrite(_)
|
| Self::AppSecretsWrite(_)
|
||||||
| Self::AppEmailSend(_) => Scope::ScriptWrite,
|
| Self::AppEmailSend(_)
|
||||||
|
| Self::AppUsersWrite(_)
|
||||||
|
| Self::AppUsersAdmin(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
Self::AppAdmin(_)
|
Self::AppAdmin(_)
|
||||||
@@ -324,6 +346,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppDocsRead(_)
|
| Capability::AppDocsRead(_)
|
||||||
| Capability::AppFilesRead(_)
|
| Capability::AppFilesRead(_)
|
||||||
| Capability::AppSecretsRead(_)
|
| Capability::AppSecretsRead(_)
|
||||||
|
| Capability::AppUsersRead(_)
|
||||||
);
|
);
|
||||||
let in_editor = in_viewer
|
let in_editor = in_viewer
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -337,6 +360,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppPubsubPublish(_)
|
| Capability::AppPubsubPublish(_)
|
||||||
| Capability::AppSecretsWrite(_)
|
| Capability::AppSecretsWrite(_)
|
||||||
| Capability::AppEmailSend(_)
|
| Capability::AppEmailSend(_)
|
||||||
|
| Capability::AppUsersWrite(_)
|
||||||
);
|
);
|
||||||
let in_app_admin = in_editor
|
let in_app_admin = in_editor
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -346,6 +370,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppManageTriggers(_)
|
| Capability::AppManageTriggers(_)
|
||||||
| Capability::AppDeadLetterManage(_)
|
| Capability::AppDeadLetterManage(_)
|
||||||
| Capability::AppTopicManage(_)
|
| Capability::AppTopicManage(_)
|
||||||
|
| Capability::AppUsersAdmin(_)
|
||||||
);
|
);
|
||||||
match role {
|
match role {
|
||||||
AppRole::Viewer => in_viewer,
|
AppRole::Viewer => in_viewer,
|
||||||
@@ -718,6 +743,66 @@ mod tests {
|
|||||||
.is_allow());
|
.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]
|
#[test]
|
||||||
fn capability_app_id_extraction() {
|
fn capability_app_id_extraction() {
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user