diff --git a/crates/manager-core/src/admin_users_api.rs b/crates/manager-core/src/admin_users_api.rs index 5bd5466..a1dd465 100644 --- a/crates/manager-core/src/admin_users_api.rs +++ b/crates/manager-core/src/admin_users_api.rs @@ -14,18 +14,17 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; -use axum::Router; +use axum::{Extension, Router}; use chrono::{DateTime, Utc}; -use picloud_shared::AdminUserId; +use picloud_shared::{AdminUserId, InstanceRole, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; -use picloud_shared::InstanceRole; - use crate::admin_session_repo::AdminSessionRepository; use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; use crate::api_key_repo::ApiKeyRepository; use crate::auth::hash_password; +use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; /// Validation knobs are tuned by NIST 800-63B-ish guidance: username is /// a strict ASCII subset so the lookup column stays predictable, and @@ -43,6 +42,9 @@ pub struct AdminsState { /// also expires every active API key for that user so cookie and /// bearer credentials become inert at the same moment. pub keys: Arc, + /// Capability gate: every endpoint here requires + /// `InstanceManageUsers` (owner / admin). + pub authz: Arc, } pub fn admins_router(state: AdminsState) -> Router { @@ -113,15 +115,29 @@ pub struct PatchAdminRequest { async fn list_admins( State(state): State, + Extension(principal): Extension, ) -> Result>, AdminApiError> { + require( + state.authz.as_ref(), + &principal, + Capability::InstanceManageUsers, + ) + .await?; let rows = state.users.list().await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } async fn get_admin( State(state): State, + Extension(principal): Extension, Path(id): Path, ) -> Result, AdminApiError> { + require( + state.authz.as_ref(), + &principal, + Capability::InstanceManageUsers, + ) + .await?; state .users .get(id) @@ -133,8 +149,24 @@ async fn get_admin( async fn create_admin( State(state): State, + Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json), AdminApiError> { + require( + state.authz.as_ref(), + &principal, + Capability::InstanceManageUsers, + ) + .await?; + // Minting an owner via the API requires the caller to ALSO be an + // owner — admin cannot self-elevate (or elevate someone else) + // beyond their own ceiling. Owner-creation by env-var bootstrap + // bypasses this path. + if input.instance_role == InstanceRole::Owner + && principal.instance_role != InstanceRole::Owner + { + return Err(AdminApiError::CannotEscalate); + } let username = input.username.trim(); validate_username(username)?; validate_password(&input.password)?; @@ -148,9 +180,16 @@ async fn create_admin( async fn patch_admin( State(state): State, + Extension(principal): Extension, Path(id): Path, Json(input): Json, ) -> Result, AdminApiError> { + require( + state.authz.as_ref(), + &principal, + Capability::InstanceManageUsers, + ) + .await?; // Verify the target exists upfront — keeps the error path uniform // for "rename a missing user" etc. let current = state @@ -179,6 +218,12 @@ async fn patch_admin( } if let Some(new_role) = input.instance_role { + // Self-elevation guard: only an owner can promote anyone TO + // owner. An admin cannot turn themselves (or anyone else) + // into one. + if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner { + return Err(AdminApiError::CannotEscalate); + } // Last-active-owner guard: a transition off of `Owner` cannot // leave the install with zero owners. The check is on the // source role (current.instance_role) so demoting an @@ -249,8 +294,15 @@ async fn patch_admin( async fn delete_admin( State(state): State, + Extension(principal): Extension, Path(id): Path, ) -> Result { + require( + state.authz.as_ref(), + &principal, + Capability::InstanceManageUsers, + ) + .await?; let target = state .users .get(id) @@ -328,6 +380,15 @@ pub enum AdminApiError { #[error("cannot leave the system with zero active owners")] LastActiveOwner, + #[error("only an owner can grant the owner role")] + CannotEscalate, + + #[error("forbidden")] + Forbidden, + + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("failed to hash password: {0}")] Hash(String), @@ -335,6 +396,15 @@ pub enum AdminApiError { Repo(#[from] AdminUserRepositoryError), } +impl From for AdminApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + impl IntoResponse for AdminApiError { fn into_response(self) -> Response { let (status, message) = match &self { @@ -347,9 +417,18 @@ impl IntoResponse for AdminApiError { | Self::InvalidPassword(_) | Self::LastActiveAdmin | Self::LastActiveOwner + | Self::CannotEscalate | Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => { (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) } + Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "admin_users authz error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } Self::Repo(AdminUserRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, self.to_string()) } diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index b949a75..72965bf 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -9,14 +9,16 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, routing::get, - Json, Router, + Extension, Json, Router, }; use picloud_shared::{ - AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError, + AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator, + ValidationError, }; use serde::Deserialize; use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::repo::{ ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, }; @@ -31,6 +33,10 @@ pub struct AdminState { /// App lookups: validates `app_id` on create, resolves `?app=` /// filter on list. Trait-object so apps_repo can stay separate. pub apps: Arc, + /// Phase 3.5 capability checks — every script handler resolves + /// `AppRead/Write/LogRead(script.app_id)` against this repo after + /// loading the resource. + pub authz: Arc, pub validator: Arc, pub sandbox_ceiling: SandboxCeiling, } @@ -41,6 +47,7 @@ impl Clone for AdminState { repo: self.repo.clone(), logs: self.logs.clone(), apps: self.apps.clone(), + authz: self.authz.clone(), validator: self.validator.clone(), sandbox_ceiling: self.sandbox_ceiling, } @@ -129,14 +136,22 @@ where async fn list_scripts( State(state): State>, + Extension(principal): Extension, Query(q): Query, ) -> Result>, ApiError> { + // Membership filter: `member` users see only scripts in apps they + // belong to. `?app=` filters further by app and additionally + // requires the member to belong to that app (the read check uses + // the resource's app_id). if let Some(ident) = q.app { let app = resolve_app_ident(state.apps.as_ref(), &ident).await?; - Ok(Json(state.repo.list_for_app(app).await?)) - } else { - Ok(Json(state.repo.list().await?)) + require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?; + return Ok(Json(state.repo.list_for_app(app).await?)); } + if principal.instance_role == InstanceRole::Member { + return Ok(Json(state.repo.list_for_user(principal.user_id).await?)); + } + Ok(Json(state.repo.list().await?)) } /// Accept `?app=` OR `?app=`. Slugs route through history @@ -159,20 +174,34 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result( State(state): State>, + Extension(principal): Extension, Path(id): Path, ) -> Result, ApiError> { - state - .repo - .get(id) - .await? - .map(Json) - .ok_or(ApiError::NotFound(id)) + let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?; + require( + state.authz.as_ref(), + &principal, + Capability::AppRead(script.app_id), + ) + .await?; + Ok(Json(script)) } async fn create_script( State(state): State>, + Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json