feat(manager-core,picloud): per-handler require(capability) checks

Every admin endpoint now resolves Capability for the loaded resource
and calls authz::require(...) before mutating. Forbidden → 403; every
handler State carries an Arc<dyn AuthzRepo>, plumbed from the new
PostgresAppMembersRepository in the picloud binary.

* api.rs (scripts): AppRead/AppWriteScript/AppLogRead bound to
  script.app_id after load. List branches on instance_role:
  Member → list_for_user, others → list (or ?app= filtered).
* apps_api.rs: InstanceCreateApp on POST; AppRead on get/list_domains;
  AppAdmin on patch/delete/slug:check; AppManageDomains on
  create_domain/delete_domain. list_apps membership-filters for Member.
* admin_users_api.rs: InstanceManageUsers on every endpoint. Mint +
  PATCH refuse to grant Owner unless the caller is already Owner
  (CannotEscalate / 422), on top of the existing last-owner guard.
* route_admin.rs: AppRead on list/check/match; AppWriteRoute on
  create/delete bound to the route's actual app_id (added a
  RouteRepository::get(uuid) lookup so delete binds correctly).
* AppRepository + ScriptRepository gain list_for_user(user_id) for
  membership-filtered listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 22:13:45 +02:00
parent 8659a58eb2
commit d229120df6
8 changed files with 425 additions and 31 deletions

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
};
use sqlx::PgPool;
@@ -27,6 +27,14 @@ pub trait ScriptRepository: Send + Sync {
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
/// per-handler capability check fires.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
async fn update(
&self,
@@ -117,6 +125,24 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
FROM scripts s \
JOIN app_members m ON m.app_id = s.app_id \
WHERE m.user_id = $1 \
ORDER BY s.name",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));