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:
@@ -10,14 +10,15 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
@@ -30,6 +31,8 @@ pub struct RouteAdminState<RR, SR> {
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
@@ -39,6 +42,7 @@ impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.clone(),
|
||||
table: self.table.clone(),
|
||||
authz: self.authz.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,13 +134,26 @@ pub struct MatchedRoute {
|
||||
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -154,6 +171,12 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
@@ -196,8 +219,22 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
// Resolve the route's app before we delete, so the capability
|
||||
// binds to the actual route's app_id (not a path param).
|
||||
let route = state
|
||||
.routes
|
||||
.get(route_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(route.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.routes.delete(route_id).await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -205,8 +242,18 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||
// routes:check is read-only — peeking at a hypothetical conflict
|
||||
// is bounded by AppRead on the target app (otherwise members
|
||||
// could probe other apps).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
@@ -235,8 +282,15 @@ async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
@@ -415,16 +469,34 @@ pub enum RouteApiError {
|
||||
#[error("script not found: {0}")]
|
||||
ScriptNotFound(ScriptId),
|
||||
|
||||
#[error("route not found: {0}")]
|
||||
RouteNotFound(Uuid),
|
||||
|
||||
#[error("host {host:?} is not claimed by this app")]
|
||||
HostNotClaimed {
|
||||
host: String,
|
||||
available_claims: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for RouteApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for RouteApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
@@ -443,10 +515,23 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_)
|
||||
| Self::RouteNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "route authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::HostNotClaimed {
|
||||
host,
|
||||
available_claims,
|
||||
|
||||
Reference in New Issue
Block a user