Files
PiCloud/crates/manager-core/src/apps_api.rs
MechaCat02 4644ea4919 feat(manager-core): admin is implicit app_admin; delete-script needs AppAdmin
Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:27:32 +02:00

620 lines
20 KiB
Rust

//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
//!
//! All endpoints are guarded by `require_admin`. Per-app permissions
//! are deferred (every authenticated admin can act on every app); the
//! middleware seam exists for when that lands.
//!
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
//! list rejected. Slug renames record the old slug in
//! `app_slug_history` for permanent 301 redirects; reclaiming a
//! historical slug requires `"force_takeover": true` in the request.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId, AppRole, 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, AuthzError, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
const SLUG_MIN: usize = 1;
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
];
#[derive(Clone)]
pub struct AppsState {
pub apps: Arc<dyn AppRepository>,
pub domains: Arc<dyn AppDomainRepository>,
pub routes: Arc<dyn RouteRepository>,
/// 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 {
Router::new()
.route("/apps", get(list_apps).post(create_app))
.route(
"/apps/{id_or_slug}",
get(get_app).patch(patch_app).delete(delete_app),
)
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
.route(
"/apps/{id_or_slug}/domains",
get(list_domains).post(create_domain),
)
.route(
"/apps/{id_or_slug}/domains/{domain_id}",
delete(delete_domain),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppDto {
#[serde(flatten)]
pub app: App,
}
#[derive(Debug, Deserialize)]
pub struct CreateAppRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Set to `true` to consume an existing `app_slug_history` row for
/// the requested slug (breaking old redirects).
#[serde(default)]
pub force_takeover: bool,
}
#[derive(Debug, Deserialize)]
pub struct PatchAppRequest {
pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_optional")]
#[allow(clippy::option_option)]
pub description: Option<Option<String>>,
pub slug: Option<String>,
#[serde(default)]
pub force_takeover: bool,
}
#[allow(clippy::option_option)]
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(d).map(Some)
}
#[derive(Debug, Deserialize)]
pub struct SlugCheckRequest {
pub new_slug: String,
}
#[derive(Debug, Serialize)]
pub struct SlugCheckResponse {
pub ok: bool,
pub conflict_kind: Option<&'static str>,
pub current_app: Option<App>,
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDomainRequest {
pub pattern: String,
}
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
/// a cascading delete that also removes every script in the app (and
/// thereby their routes and execution logs). Without it the request is
/// rejected when the app still contains scripts.
#[derive(Debug, Default, Deserialize)]
pub struct DeleteAppQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct AppLookupResponse {
#[serde(flatten)]
pub app: App,
/// When the operator hits the API with a retired slug, this points
/// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` and `Admin` both map to `app_admin` (implicit per
/// blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
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
// and the caller hasn't asked to force takeover, surface a clean
// 409 so the dashboard can present a "this will break old links"
// confirmation.
if !input.force_takeover {
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
}
let created = if input.force_takeover {
s.apps
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
.await?
} else {
s.apps
.create(&input.slug, &input.name, input.description.as_deref())
.await?
};
Ok((StatusCode::CREATED, Json(created)))
}
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 {
None
};
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
Ok(Json(AppLookupResponse {
app: lookup.app,
redirect_to,
my_role,
}))
}
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
}
}
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).
let after_meta = if input.name.is_some() || input.description.is_some() {
s.apps
.update(
current.id,
input.name.as_deref(),
input.description.as_ref().map(|d| d.as_deref()),
)
.await?
} else {
current
};
// Slug rename is a separate operation; the rename method does its
// own history bookkeeping in a transaction.
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
validate_slug(new_slug)?;
match s
.apps
.rename_slug(after_meta.id, new_slug, input.force_takeover)
.await
{
Ok(app) => app,
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
return Err(AppsApiError::Conflict(msg));
}
Err(e) => return Err(e.into()),
}
} else {
after_meta
};
Ok(Json(after_rename))
}
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?;
} else {
// Soft pre-check for a clean error; the DB FK is the real guard
// (ON DELETE RESTRICT on scripts.app_id).
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
if n_scripts > 0 {
return Err(AppsApiError::HasScripts(n_scripts));
}
s.apps.delete(app.id).await?;
}
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn slug_check(
State(s): State<AppsState>,
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 {
ok: false,
conflict_kind: Some("invalid"),
current_app: None,
reason: Some(reason),
}));
}
Err(other) => return Err(other),
Ok(()) => {}
}
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("current"),
current_app: Some(app),
reason: Some("another app currently uses this slug".into()),
}));
}
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("historical"),
current_app: Some(app),
reason: Some("slug is a retired redirect; using it will break old links".into()),
}));
}
Ok(Json(SlugCheckResponse {
ok: true,
conflict_kind: None,
current_app: None,
reason: None,
}))
}
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
.create(NewAppDomain {
app_id: app.id,
pattern: input.pattern,
shape: parsed.shape,
shape_key: parsed.shape_key,
})
.await?;
refresh_domain_cache(&s).await?;
Ok((StatusCode::CREATED, Json(created)))
}
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));
};
if domain.app_id != app.id {
return Err(AppsApiError::DomainNotFound(domain_id));
}
// Guard: routes inside this app may reference this exact host
// pattern. The host-kind on the route is `strict` or `wildcard`
// (Any routes don't pin a specific host). We block deletion in
// either case and let the operator clean up first.
let strict = s
.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
.await?;
let wild_suffix = domain
.pattern
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let wild = if wild_suffix.is_empty() {
0
} else {
s.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
.await?
};
if strict + wild > 0 {
return Err(AppsApiError::DomainHasRoutes(strict + wild));
}
s.domains.delete(domain_id).await?;
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await?
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
}
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
return Err(AppsApiError::InvalidSlug(format!(
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
)));
}
if !slug
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return Err(AppsApiError::InvalidSlug(
"slug must start with [a-z0-9]".into(),
));
}
for c in slug.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err(AppsApiError::InvalidSlug(
"slug may only contain lowercase letters, digits, and '-'".into(),
));
}
}
if RESERVED_SLUGS.contains(&slug) {
return Err(AppsApiError::InvalidSlug(format!(
"slug {slug:?} is reserved for system use"
)));
}
Ok(())
}
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
/// Called after every domain CRUD operation.
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
let all = state.domains.list_all().await?;
let compiled = all
.into_iter()
.filter_map(|d| {
// Parse the stored pattern; skip on parse error rather than
// poisoning the entire cache. The handlers reject bad input,
// so this is purely defensive against a future migration
// that loosens the constraints.
pattern::parse_app_domain(&d.pattern)
.ok()
.map(|p| CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
state.domain_table.replace(compiled);
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppsApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("domain not found: {0}")]
DomainNotFound(Uuid),
#[error("invalid slug: {0}")]
InvalidSlug(String),
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
SlugInHistory(App),
#[error("app still contains {0} script(s); delete or move them first")]
HasScripts(i64),
#[error("domain has {0} route(s) bound to it; delete the routes first")]
DomainHasRoutes(i64),
#[error("invalid pattern: {0}")]
Pattern(#[from] pattern::ParseError),
#[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 From<AuthzError> for AppsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::DomainNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::InvalidSlug(_) | Self::Pattern(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::SlugInHistory(current) => (
StatusCode::CONFLICT,
json!({
"error": self.to_string(),
"conflict_kind": "historical",
"current_app": current,
}),
),
Self::HasScripts(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "script_count": n }),
),
Self::DomainHasRoutes(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "route_count": n }),
),
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");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}