feat(manager-core): admin is implicit app_admin; delete-script needs AppAdmin

Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 19:27:32 +02:00
parent ec3c768262
commit 4644ea4919
5 changed files with 94 additions and 67 deletions

View File

@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
Capability::AppAdmin(script.app_id),
)
.await?;
state.repo.delete(id).await?;

View File

@@ -143,8 +143,8 @@ pub struct AppLookupResponse {
pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
/// per blueprint §11.6); `Member` carries its explicit
/// `Owner` and `Admin` both map to `app_admin` (implicit per
/// blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
}
@@ -226,16 +226,15 @@ async fn get_app(
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
/// everywhere; `Member` consults `app_members`.
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
}
}

View File

@@ -199,21 +199,14 @@ async fn role_grants(
}
}
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
/// can create apps and manage users, but NOT touch instance-wide
/// settings or take app-admin-only actions on apps they're not
/// explicitly app_admin of. Everything not in this set falls through
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
matches!(
cap,
Capability::InstanceCreateApp
| Capability::InstanceManageUsers
| Capability::AppRead(_)
| Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppLogRead(_)
)
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
@@ -357,10 +350,23 @@ mod tests {
}
#[tokio::test]
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
@@ -371,36 +377,22 @@ mod tests {
.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
// Editor-like grants succeed
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap(),
Decision::Allow,
);
// App-admin grants do not
assert_eq!(
can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap(),
Decision::Deny,
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"admin denied app-scoped capability {cap:?}"
);
}
}
#[tokio::test]
@@ -474,6 +466,29 @@ mod tests {
);
}
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();

View File

@@ -293,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
let s = boot(pool.clone()).await;
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let token = login_token(&s.server, "alice", "alice-pw").await;
@@ -305,24 +305,32 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
.await
.assert_status_ok();
// Allowed: read default app (admin is implicit editor everywhere).
// Allowed: read default app admin is implicit app_admin
// everywhere (per blueprint §11.6).
s.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Allowed: write scripts (implicit editor).
// Allowed: write scripts.
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
assert!(script["id"].is_string());
// Denied: delete the default app (AppAdmin only).
let denied = s
.server
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
// 403'd; now it's the same allow as the owner sees.
s.server
.get("/api/v1/admin/apps/default/members")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Allowed: delete the default app (AppAdmin).
s.server
.delete("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -735,7 +743,7 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
"owner reports app_admin"
);
// Admin → implicit editor everywhere.
// Admin → implicit app_admin everywhere (post-§11.6 update).
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
let r = s
@@ -746,8 +754,8 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("editor"),
"admin reports editor"
Some("app_admin"),
"admin reports app_admin"
);
// Member with explicit `viewer` membership → viewer.