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

@@ -12,12 +12,12 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppRepository, AppsState, AuthState, PostgresAdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling,
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
@@ -88,7 +88,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool));
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// Authz: app_members repo doubles as the AuthzRepo impl for the
// per-handler capability checks introduced in Phase 3.5.
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
@@ -123,6 +126,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(),
};
@@ -131,6 +135,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(),
table: route_table.clone(),
authz: authz.clone(),
};
let data_plane = DataPlaneState {
executor,
@@ -144,6 +149,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
domains: domains_repo,
routes: route_repo,
domain_table: app_domain_table,
authz: authz.clone(),
};
let auth_state = AuthState {
@@ -156,6 +162,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
users: auth.users,
sessions: auth.sessions,
keys: auth.keys.clone(),
authz,
};
let api_keys_state = ApiKeysState {
keys: auth.keys,
@@ -258,6 +265,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_app(app_id).await
}
async fn list_for_user(
&self,
user_id: picloud_shared::AdminUserId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_user(user_id).await
}
async fn create(
&self,
input: picloud_manager_core::NewScript,