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

@@ -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<dyn ApiKeyRepository>,
/// Capability gate: every endpoint here requires
/// `InstanceManageUsers` (owner / admin).
pub authz: Arc<dyn AuthzRepo>,
}
pub fn admins_router(state: AdminsState) -> Router {
@@ -113,15 +115,29 @@ pub struct PatchAdminRequest {
async fn list_admins(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, 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<AdminsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAdminRequest>,
) -> Result<(StatusCode, Json<AdminDto>), 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> {
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<AuthzDenied> 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())
}

View File

@@ -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())
}

View File

@@ -6,7 +6,7 @@
//! that writes the history row in the same transaction.
use async_trait::async_trait;
use picloud_shared::{App, AppId};
use picloud_shared::{AdminUserId, App, AppId};
use sqlx::PgPool;
use crate::repo::ScriptRepositoryError;
@@ -22,7 +22,15 @@ pub struct AppLookup {
#[async_trait]
pub trait AppRepository: Send + Sync {
/// Every app on the instance. For owner/admin callers — `member`
/// users go through `list_for_user`.
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
/// Only apps the user has an `app_members` row for. Drives the
/// membership-filtered `GET /admin/apps` for `member` callers.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<App>, ScriptRepositoryError>;
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history(
@@ -92,6 +100,23 @@ impl AppRepository for PostgresAppRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
FROM apps a \
JOIN app_members m ON m.app_id = a.id \
WHERE m.user_id = $1 \
ORDER BY a.name",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \

View File

@@ -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");
(

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
};
use sqlx::PgPool;
@@ -27,6 +27,14 @@ pub trait ScriptRepository: Send + Sync {
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
/// per-handler capability check fires.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
async fn update(
&self,
@@ -117,6 +125,24 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
FROM scripts s \
JOIN app_members m ON m.app_id = s.app_id \
WHERE m.user_id = $1 \
ORDER BY s.name",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));

View File

@@ -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,

View File

@@ -25,6 +25,10 @@ pub struct NewRoute {
#[async_trait]
pub trait RouteRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
/// the capability check binds to the route's actual `app_id`
/// (not a path param).
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn list_for_script(
&self,
@@ -66,6 +70,18 @@ impl RouteRepository for PostgresRouteRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes WHERE id = $1",
)
.bind(route_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \