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:
@@ -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<R, L> {
|
||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
/// 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<dyn AuthzRepo>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
@@ -41,6 +47,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
||||
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<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Query(q): Query<ListScriptsQuery>,
|
||||
) -> Result<Json<Vec<Script>>, 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=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||
@@ -159,20 +174,34 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<Json<Script>, 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<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateScriptRequest>,
|
||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||
// Capability is bound to the *requested* app_id since there's no
|
||||
// resource to load yet. If the app doesn't exist we 422 below;
|
||||
// checking authz first means a Member trying to create against an
|
||||
// unknown app gets 403 (no enumeration of app existence).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.validator.validate(&input.source)?;
|
||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
@@ -201,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Json(input): Json<UpdateScriptRequest>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
if let Some(src) = input.source.as_deref() {
|
||||
state.validator.validate(src)?;
|
||||
}
|
||||
@@ -229,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -249,9 +294,17 @@ const fn default_limit() -> i64 {
|
||||
|
||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppLogRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
// Cap to keep the dashboard responsive; the data plane writes are
|
||||
// unbounded over time so a paged read is the only sane default.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
@@ -281,10 +334,25 @@ pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Ceiling(#[from] CeilingError),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for ApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
@@ -294,6 +362,14 @@ impl IntoResponse for ApiError {
|
||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user