//! Admin HTTP surface for the declarative reconcile engine. //! //! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle //! against the app's live state and return the plan. Read-only; requires //! `AppRead`. The `apply` route (write path) lands in the next milestone. use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Extension, Json, Router, }; use picloud_shared::{AppId, GroupId, Principal}; use serde::Deserialize; use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; /// Build the apply/plan router. Mounted under `/api/v1/admin`. pub fn apply_router(service: ApplyService) -> Router { Router::new() .route("/apps/{id}/plan", post(plan_handler)) .route("/apps/{id}/apply", post(apply_handler)) .route("/groups/{id}/plan", post(group_plan_handler)) .route("/groups/{id}/apply", post(group_apply_handler)) .route("/tree/plan", post(tree_plan_handler)) .route("/tree/apply", post(tree_apply_handler)) .route( "/apps/{id}/extension-points", get(app_extension_points_handler), ) .route( "/groups/{id}/extension-points", get(group_extension_points_handler), ) .route("/groups/{id}/collections", get(group_collections_handler)) .route("/groups/{id}/triggers", get(group_triggers_handler)) .route("/groups/{id}/routes", get(group_routes_handler)) .route("/apps/{id}/suppressions", get(app_suppressions_handler)) .route("/groups/{id}/suppressions", get(group_suppressions_handler)) .with_state(service) } /// Read-only §11 tail suppression report for an app: the inherited templates it /// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs /// `pic suppress ls --app`. async fn app_suppressions_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; let report = svc.suppression_report(ApplyOwner::App(app_id)).await?; Ok(Json(report)) } /// Read-only §11 tail M1 suppression report for a group: the inherited templates /// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier /// `GroupScriptsRead`. Backs `pic suppress ls --group`. async fn group_suppressions_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?; Ok(Json(report)) } /// Read-only §11 tail route-template report for a group: its own declared route /// templates (method, host, path, handler script, dispatch, enabled). Viewer- /// tier read. Backs `pic routes ls --group`. async fn group_routes_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let report = svc.route_report(ApplyOwner::Group(group_id)).await?; Ok(Json(report)) } /// Read-only §11 tail trigger-template report for a group: its own declared /// event trigger templates (kind, target, handler script, enabled). Viewer-tier /// read. Backs `pic triggers ls --group`. async fn group_triggers_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?; Ok(Json(report)) } /// Read-only §11.6 shared-collection report for a group: its own declared /// shared KV collection names. Viewer-tier read. async fn group_collections_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let report = svc.collection_report(ApplyOwner::Group(group_id)).await?; Ok(Json(report)) } /// Read-only §5.5 extension-point report for an app: every EP visible on its /// chain, each with `declared_here` + resolved `provider`. Viewer-tier read. async fn app_extension_points_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?; Ok(Json(report)) } /// Read-only §5.5 extension-point report for a group: its own declared names. async fn group_extension_points_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let report = svc .extension_point_report(ApplyOwner::Group(group_id)) .await?; Ok(Json(report)) } /// A `plan` request (§7 M3): the desired bundle plus the optional declaring /// `[project]`, so the plan can preview the ownership outcome + attach ceiling. #[derive(Deserialize)] pub struct PlanRequest { // Flattened so the bundle fields sit at the JSON top level — restoring the // pre-§7 plan wire, which took a BARE `Json`. An older `pic` that // POSTs a bare bundle still deserializes (`project` defaults to `None`), and // a newer client sends the same bundle fields plus a top-level `project` // key. (Apply is deliberately NOT flattened — its pre-§7 wire was already // `{bundle, prune, …}`, so flattening it would instead break old apply // clients; the two endpoints are asymmetric because their histories are.) #[serde(flatten)] pub bundle: Bundle, #[serde(default)] pub project: Option, } #[derive(Deserialize)] pub struct ApplyRequest { pub bundle: Bundle, #[serde(default)] pub prune: bool, /// Optional bound-plan token from a prior `plan`. When present, apply /// refuses (409) if the app's live state has changed since. #[serde(default)] pub expected_token: Option, /// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also /// carries the §3 M3 env approval policy (`project.environments`). #[serde(default)] pub project: Option, /// §7 ownership: reassign a node owned by another project (group-admin). #[serde(default)] pub takeover: bool, /// §3 M3: the environment this apply targets (the overlay merged). Drives the /// per-env approval gate when the project marks it `confirm = true`. #[serde(default)] pub env: Option, /// §3 M3: environments the actor explicitly approved (`--approve `). A /// confirm-required env is refused unless it appears here; `--yes` alone does /// NOT add it. #[serde(default)] pub approved_envs: Vec, } async fn apply_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(req): Json, ) -> Result, ApplyError> { let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?; // §3 M3: server-authoritative per-env approval gate. The governing policy is // loaded from the project the SERVER resolves as owning this node (its // nearest-claimed ancestor), unioned with the declared policy — so a request // that omits or spoofs `[project]` can't bypass a gate the owning project // established. If gated and approved, the override additionally requires // ADMIN on the node (a step up from the editor write caps above) + is audited. let policy = svc .governing_env_policy(ApplyOwner::App(app_id), req.project.as_ref()) .await?; if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? { require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id)) .await .map_err(map_authz)?; audit_gated_apply(&principal, req.env.as_deref(), 1); } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, principal: &principal, }; let report = svc .apply( app_id, &req.bundle, req.prune, &claim, req.expected_token.as_deref(), ) .await?; Ok(Json(report)) } async fn plan_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(req): Json, ) -> Result, ApplyError> { let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?; // NOTE: the returned `Plan` discloses live secret NAMES (not values). That // is safe today only because `AppRead` and `AppSecretsRead` are co-granted // at every tier (same `script:read` scope, both in the viewer role). If a // future authz split puts `AppSecretsRead` on its own tier, this handler // must additionally require it — otherwise it leaks names a principal // couldn't enumerate via the secrets API. require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; let plan = svc.plan(app_id, &req.bundle, req.project.as_ref()).await?; Ok(Json(plan)) } // ---------------------------------------------------------------------------- // Group node apply (Phase 5): a group declares only scripts + vars (+ secret // names). The bundle reuses the app `Bundle` shape with empty routes/triggers; // the service rejects routes/triggers on a group node. // ---------------------------------------------------------------------------- async fn group_plan_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(req): Json, ) -> Result, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; // Plan discloses the group's script + var + secret names; viewer-tier reads. require( svc.authz.as_ref(), &principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; let plan = svc .plan_owner( ApplyOwner::Group(group_id), &req.bundle, req.project.as_ref(), ) .await?; Ok(Json(plan)) } async fn group_apply_handler( State(svc): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(req): Json, ) -> Result, ApplyError> { let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune) .await?; // §3 M3: per-env approval gate (governing ∪ declared, server-resolved owner) // — an approved gated apply requires GroupAdmin. let policy = svc .governing_env_policy(ApplyOwner::Group(group_id), req.project.as_ref()) .await?; if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? { require( svc.authz.as_ref(), &principal, Capability::GroupAdmin(group_id), ) .await .map_err(map_authz)?; audit_gated_apply(&principal, req.env.as_deref(), 1); } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, principal: &principal, }; let report = svc .apply_owner( ApplyOwner::Group(group_id), &req.bundle, req.prune, &claim, req.expected_token.as_deref(), ) .await?; Ok(Json(report)) } // ---------------------------------------------------------------------------- // Tree apply (Phase 5): a whole project subtree in one transaction. The actor // must hold the relevant read/write caps for EVERY node touched. // ---------------------------------------------------------------------------- #[derive(Deserialize)] struct TreeApplyRequest { bundle: TreeBundle, #[serde(default)] prune: bool, #[serde(default)] expected_token: Option, /// §7 ownership: one `[project]` for the whole tree (the root manifest's). /// Also carries the §3 M3 env approval policy (`project.environments`). #[serde(default)] project: Option, #[serde(default)] takeover: bool, /// §6 M2: how to resolve a group whose server parent diverges from the /// manifest (default `Refuse`; a pre-M2 CLI omits it). #[serde(default)] structure_mode: StructureMode, /// §3 M3: the environment this apply targets (drives the per-env approval gate). #[serde(default)] env: Option, /// §3 M3: environments the actor explicitly approved (`--approve `). #[serde(default)] approved_envs: Vec, } #[derive(Deserialize)] struct TreePlanRequest { // Flattened for the same bare-bundle wire compatibility as `PlanRequest`. #[serde(flatten)] bundle: TreeBundle, #[serde(default)] project: Option, } async fn tree_plan_handler( State(svc): State, Extension(principal): Extension, Json(req): Json, ) -> Result, ApplyError> { authz_tree(&svc, &principal, &req.bundle, None).await?; Ok(Json( svc.plan_tree(&req.bundle, req.project.as_ref()).await?, )) } async fn tree_apply_handler( State(svc): State, Extension(principal): Extension, Json(req): Json, ) -> Result, ApplyError> { authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?; // §3 M3: server-authoritative per-env approval gate (governing ∪ declared). // The governing side is the union of every declared node's owning-project // policy, resolved server-side — omitting `[project]` can't dodge it. On an // approved gated apply, require ADMIN on EVERY declared node (a step up from // the editor write caps authz_tree checks) + audit. let policy = svc .governing_env_policy_tree(&req.bundle, req.project.as_ref()) .await?; if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? { require_admin_on_every_node(&svc, &principal, &req.bundle).await?; audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len()); } let claim = OwnershipClaim { project: req.project, takeover: req.takeover, principal: &principal, }; let report = svc .apply_tree( &req.bundle, req.prune, &claim, req.expected_token.as_deref(), req.structure_mode, ) .await?; Ok(Json(report)) } /// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the /// EFFECTIVE policy (`governing ∪ declared`, built by /// `ApplyService::governing_env_policy`) — the governing side is loaded from the /// project the SERVER resolves as owning the node, so a request that omits or /// weakens `[project]` can't bypass a gate the owning project established. /// Returns `Ok(true)` when the target env is confirm-required /// AND approved — the caller must then require admin + audit. `Ok(false)` when /// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)` /// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here). fn env_gate_check( policy: &std::collections::BTreeMap, env: Option<&str>, approved_envs: &[String], ) -> Result { let Some(env) = env else { return Ok(false); }; if !policy.get(env).copied().unwrap_or(false) { return Ok(false); } if approved_envs.iter().any(|e| e == env) { Ok(true) } else { Err(ApplyError::ApprovalRequired(env.to_string())) } } /// §3 M3: require `AppAdmin`/`GroupAdmin` on every declared node of a tree apply /// (the "override a gate" authority, above editor write caps). Mirrors /// `authz_tree`'s resolution: an app resolves UUID-or-slug (fail-closed); a group /// node whose slug names no existing group is to-CREATE (its create is separately /// admin-gated in the service), so it is skipped here rather than 404'd. async fn require_admin_on_every_node( svc: &ApplyService, principal: &Principal, bundle: &TreeBundle, ) -> Result<(), ApplyError> { for node in &bundle.nodes { match node.kind { NodeKind::App => { let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?; require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id)) .await .map_err(map_authz)?; } NodeKind::Group => { if let Some(group) = svc .groups .get_by_slug(&node.slug) .await .map_err(|e| ApplyError::Backend(e.to_string()))? { require( svc.authz.as_ref(), principal, Capability::GroupAdmin(group.id), ) .await .map_err(map_authz)?; } } } } Ok(()) } /// §3 M3: audit an approved gated-environment apply (actor + env + node count). fn audit_gated_apply(principal: &Principal, env: Option<&str>, nodes: usize) { tracing::info!( user_id = ?principal.user_id, env = ?env, nodes, "apply: approved gated-environment apply (audit)" ); } /// Per-node capability check for a tree plan/apply. `prune` widens an apply's /// write requirement to every kind. A plan (`prune` ignored, no writes) needs /// only the per-node read cap. async fn authz_tree( svc: &ApplyService, principal: &Principal, bundle: &TreeBundle, apply_prune: Option, ) -> Result<(), ApplyError> { for node in &bundle.nodes { match node.kind { NodeKind::App => { let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?; match apply_prune { Some(prune) => { require_app_node_writes(svc, principal, app_id, &node.bundle, prune) .await?; } None => { require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; } } } NodeKind::Group => { // §6: a group node that doesn't exist yet is to-CREATE — its // authorization (InstanceCreateGroup / GroupAdmin(parent)) is // enforced in the service's create path, so skip the // existing-group capability check here rather than 404. let Some(group) = svc .groups .get_by_slug(&node.slug) .await .map_err(|e| ApplyError::Backend(e.to_string()))? else { continue; }; let group_id = group.id; match apply_prune { Some(prune) => { svc.require_group_node_writes(principal, group_id, &node.bundle, prune) .await?; } None => { require( svc.authz.as_ref(), principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; } } } } } Ok(()) } /// App-node write caps: per resource kind the bundle touches, widened to all /// kinds when `prune` (which deletes empty-section resources + cascades). Read /// is always needed. Shared by the single-app and tree apply paths. async fn require_app_node_writes( svc: &ApplyService, principal: &Principal, app_id: picloud_shared::AppId, bundle: &Bundle, prune: bool, ) -> Result<(), ApplyError> { require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; if prune || !bundle.scripts.is_empty() { require( svc.authz.as_ref(), principal, Capability::AppWriteScript(app_id), ) .await .map_err(map_authz)?; } if prune || !bundle.routes.is_empty() { require( svc.authz.as_ref(), principal, Capability::AppWriteRoute(app_id), ) .await .map_err(map_authz)?; } if prune || !bundle.triggers.is_empty() { require( svc.authz.as_ref(), principal, Capability::AppManageTriggers(app_id), ) .await .map_err(map_authz)?; } if prune || !bundle.vars.is_empty() { require( svc.authz.as_ref(), principal, Capability::AppVarsWrite(app_id), ) .await .map_err(map_authz)?; } // Email triggers resolve + decrypt a stored secret by name server-side // (`AppSecretsRead`), so require it so apply can't bind a secret the // principal couldn't otherwise read. if bundle.triggers.iter().any(BundleTrigger::is_email) { require( svc.authz.as_ref(), principal, Capability::AppSecretsRead(app_id), ) .await .map_err(map_authz)?; } Ok(()) } /// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404. async fn resolve_group_id( groups: &dyn GroupRepository, ident: &str, ) -> Result { let found = if let Ok(uuid) = ident.parse::() { groups .get_by_id(uuid.into()) .await .map_err(|e| ApplyError::Backend(e.to_string()))? } else { groups .get_by_slug(ident) .await .map_err(|e| ApplyError::Backend(e.to_string()))? }; found .map(|g| g.id) .ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}"))) } /// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404. /// Mirrors the `triggers_api` helper of the same shape. async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| ApplyError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or_else(|| ApplyError::AppNotFound(ident.to_string())) } fn map_authz(denied: AuthzDenied) -> ApplyError { match denied { AuthzDenied::Denied => ApplyError::Forbidden, AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()), } } impl IntoResponse for ApplyError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), Self::Invalid(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), // 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a // §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7 // ownership conflict (declare `[project]`, `--takeover`). Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => { (StatusCode::CONFLICT, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "apply authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "apply backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } } #[cfg(test)] mod tests { use super::*; // §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a // pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no // `project`) must still deserialize — `project` defaults to `None`. #[test] fn plan_request_accepts_a_bare_bundle() { let bare = serde_json::json!({ "scripts": [], "secrets": ["TOKEN"], }); let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes"); assert!(req.project.is_none()); assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]); } // The newer wire shape — the same bundle fields at the top level plus a // `project` key (what the current CLI sends) — also deserializes. #[test] fn plan_request_accepts_a_flattened_project() { let with_proj = serde_json::json!({ "scripts": [], "project": { "slug": "acme" }, }); let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project"); assert_eq!(req.project.expect("project").slug, "acme"); } #[test] fn tree_plan_request_accepts_a_bare_bundle() { let bare = serde_json::json!({ "nodes": [] }); let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle"); assert!(req.project.is_none()); } }