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:
@@ -15,15 +15,16 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||
use picloud_shared::{App, AppDomain, AppId};
|
||||
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
@@ -41,6 +42,8 @@ pub struct AppsState {
|
||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||
/// operation so the orchestrator sees changes immediately.
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
@@ -144,14 +147,27 @@ pub struct AppLookupResponse {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||
Ok(Json(s.apps.list().await?))
|
||||
async fn list_apps(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||
// Member callers see only apps they're a member of; owner/admin
|
||||
// see everything. Filter at the SQL layer (not just in the
|
||||
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||
let apps = if principal.instance_role == InstanceRole::Member {
|
||||
s.apps.list_for_user(principal.user_id).await?
|
||||
} else {
|
||||
s.apps.list().await?
|
||||
};
|
||||
Ok(Json(apps))
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateAppRequest>,
|
||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
@@ -178,9 +194,16 @@ async fn create_app(
|
||||
|
||||
async fn get_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(lookup.app.id),
|
||||
)
|
||||
.await?;
|
||||
let redirect_to = if lookup.redirected {
|
||||
Some(lookup.app.slug.clone())
|
||||
} else {
|
||||
@@ -194,10 +217,17 @@ async fn get_app(
|
||||
|
||||
async fn patch_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchAppRequest>,
|
||||
) -> Result<Json<App>, AppsApiError> {
|
||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppAdmin(current.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Edits to name/description go first (separate from rename so we
|
||||
// don't conflate the two errors).
|
||||
@@ -240,10 +270,12 @@ async fn patch_app(
|
||||
|
||||
async fn delete_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<DeleteAppQuery>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
|
||||
if q.force {
|
||||
s.apps.delete_cascade(app.id).await?;
|
||||
@@ -262,9 +294,12 @@ async fn delete_app(
|
||||
|
||||
async fn slug_check(
|
||||
State(s): State<AppsState>,
|
||||
Path(_id_or_slug): Path<String>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SlugCheckRequest>,
|
||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
match validate_slug(&input.new_slug) {
|
||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
@@ -303,18 +338,27 @@ async fn slug_check(
|
||||
|
||||
async fn list_domains(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||
}
|
||||
|
||||
async fn create_domain(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateDomainRequest>,
|
||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageDomains(app.id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||
let created = s
|
||||
.domains
|
||||
@@ -331,9 +375,16 @@ async fn create_domain(
|
||||
|
||||
async fn delete_domain(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageDomains(app.id),
|
||||
)
|
||||
.await?;
|
||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
};
|
||||
@@ -476,10 +527,25 @@ pub enum AppsApiError {
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AppsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
@@ -511,6 +577,14 @@ impl IntoResponse for AppsApiError {
|
||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apps authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "apps api db error");
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user