//! 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, ExtPointView, NodeKind, PlanResult, TreeBundle, TreePlanResult, }; 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("/apps/{id}/extension-points", get(app_ext_points_handler)) .route( "/groups/{id}/extension-points", get(group_ext_points_handler), ) .route("/tree/plan", post(tree_plan_handler)) .route("/tree/apply", post(tree_apply_handler)) .with_state(service) } /// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point /// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`). async fn app_ext_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)?; Ok(Json( svc.list_extension_points(ApplyOwner::App(app_id)).await?, )) } /// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point /// declarations. Read-only; viewer-tier (`GroupScriptsRead`). async fn group_ext_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)?; Ok(Json( svc.list_extension_points(ApplyOwner::Group(group_id)) .await?, )) } #[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, } 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?; let report = svc .apply( app_id, &req.bundle, req.prune, principal.user_id, 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(bundle): 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, &bundle).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(bundle): 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), &bundle).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?; require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?; let report = svc .apply_owner( ApplyOwner::Group(group_id), &req.bundle, req.prune, principal.user_id, 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 TreePlanRequest { bundle: TreeBundle, /// Stable project key from the repo's `.picloud/` link state (§7, M3). /// Lets `plan` surface ownership conflicts and prune candidates. Optional /// for legacy callers (then no ownership diagnosis is reported). #[serde(default)] project_key: Option, } #[derive(Deserialize)] struct TreeApplyRequest { bundle: TreeBundle, #[serde(default)] prune: bool, #[serde(default)] expected_token: Option, /// Stable project key (§7, M3): the apply claims unclaimed declared groups /// for this project and refuses ones another project owns. #[serde(default)] project_key: Option, /// Take over declared groups owned by another project (group-admin gated). #[serde(default)] allow_takeover: bool, /// Selected environment (§4.5, M4a): the value substituted for the `{env}` /// placeholder when expanding route templates. The CLI passes `--env`. #[serde(default)] env: Option, } async fn tree_plan_handler( State(svc): State, Extension(principal): Extension, Json(req): Json, ) -> Result, ApplyError> { authz_tree(&svc, &principal, &req.bundle, None, false).await?; Ok(Json( svc.plan_tree(&req.bundle, req.project_key.as_deref()) .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), req.allow_takeover, ) .await?; let report = svc .apply_tree( &req.bundle, req.prune, &principal, req.expected_token.as_deref(), req.project_key.as_deref(), req.allow_takeover, req.env.as_deref(), ) .await?; Ok(Json(report)) } /// 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. #[allow(clippy::too_many_lines)] async fn authz_tree( svc: &ApplyService, principal: &Principal, bundle: &TreeBundle, apply_prune: Option, allow_takeover: bool, ) -> 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?; // Template expansion (§4.5) writes routes/triggers INTO // this app, so the actor needs the matching write cap // when the app's chain carries templates — even if the // app itself declares none. Without this, expanding into // an app a principal can't write would slip the gate. let recv = app_receives_template_expansions(svc, app_id, bundle).await?; if recv.routes { require( svc.authz.as_ref(), principal, Capability::AppWriteRoute(app_id), ) .await .map_err(map_authz)?; } if recv.triggers { require( svc.authz.as_ref(), principal, Capability::AppManageTriggers(app_id), ) .await .map_err(map_authz)?; } // Email-trigger expansion resolves + seals the recipient // app's secret server-side — same `AppSecretsRead` gate a // hand-declared email trigger requires. if recv.email { require( svc.authz.as_ref(), principal, Capability::AppSecretsRead(app_id), ) .await .map_err(map_authz)?; } } None => { require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) .await .map_err(map_authz)?; } } } NodeKind::Group => { let existing = svc .groups .get_by_slug(&node.slug) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; let Some(g) = existing else { // To-create group (M2): mirror the interactive create gate // (`groups_api::create_group`). A root-level group (no // resolvable parent) needs `InstanceCreateGroup`; a subgroup // under an EXISTING parent needs only `GroupAdmin(parent)`. // A parent that is itself to-create has no id yet → fall // back to `InstanceCreateGroup`. let parent = match &node.parent_slug { Some(p) => svc .groups .get_by_slug(p) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, None => None, }; match parent { Some(pg) => { require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id)) .await .map_err(map_authz)?; } None => { require( svc.authz.as_ref(), principal, Capability::InstanceCreateGroup, ) .await .map_err(map_authz)?; } } continue; }; let group_id = g.id; match apply_prune { Some(prune) => { require_group_node_writes(svc, principal, group_id, &node.bundle, prune) .await?; // Takeover gate (§7.4): seizing a group that another // project owns needs GroupAdmin specifically — a second, // independent gate on top of the write caps. We gate it // for any already-claimed node under `--takeover` (a // superset of genuinely-contested, never laxer); claiming // an unclaimed node, or applying without `--takeover`, // does not require admin. The authoritative owner rewrite // happens in-tx in `apply_tree` (which knows our project). if allow_takeover && crate::group_repo::get_group_owner(&svc.pool, group_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))? .is_some() { require( svc.authz.as_ref(), principal, Capability::GroupAdmin(group_id), ) .await .map_err(map_authz)?; } // Reparent: an explicit parent differing from the live // one needs GroupAdmin at the group, the SOURCE parent, // and the DESTINATION parent — mirroring the interactive // `reparent_group` (§5.6 triply-gated). if let Some(parent) = &node.parent_slug { if let Some(pg) = svc .groups .get_by_slug(parent) .await .map_err(|e| ApplyError::Backend(e.to_string()))? { if Some(pg.id) != g.parent_id { require( svc.authz.as_ref(), principal, Capability::GroupAdmin(group_id), ) .await .map_err(map_authz)?; if let Some(src) = g.parent_id { require( svc.authz.as_ref(), principal, Capability::GroupAdmin(src), ) .await .map_err(map_authz)?; } require( svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id), ) .await .map_err(map_authz)?; } } } } None => { require( svc.authz.as_ref(), principal, Capability::GroupScriptsRead(group_id), ) .await .map_err(map_authz)?; } } } } } // NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in // this bundle (the cross-repo subtree). Those per-recipient write caps are // gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the // post-reparent app set, each app already locked — so the checked set is by // construction the written set (no pre-tx TOCTOU, and a reparent that moves // apps under a templated group in the same apply can't slip the gate). 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)?; // Extension points are module-resolution declarations, so they gate on the // same script-write tier as modules — without this, an ext-point-only // bundle (no `default`, so no script) would pass with viewer-tier `AppRead` // and let a reader open the §5.5 import trust boundary. if prune || !bundle.scripts.is_empty() || !bundle.extension_points.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(()) } /// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars → /// `GroupVarsWrite`, both on prune. async fn require_group_node_writes( svc: &ApplyService, principal: &Principal, group_id: GroupId, bundle: &Bundle, prune: bool, ) -> Result<(), ApplyError> { // Extension points and route/trigger templates gate on the script-write // tier (see the app variant) — all are code-binding declarations. if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() || !bundle.route_templates.is_empty() || !bundle.trigger_templates.is_empty() { require( svc.authz.as_ref(), principal, Capability::GroupScriptsWrite(group_id), ) .await .map_err(map_authz)?; } if prune || !bundle.vars.is_empty() { require( svc.authz.as_ref(), principal, Capability::GroupVarsWrite(group_id), ) .await .map_err(map_authz)?; } Ok(()) } /// What template expansion (§4.5) will write into `app_id` during this tree /// apply. Returns `Recipient { routes, triggers, email }` — whether the app's /// ancestor chain carries a route / trigger / EMAIL-trigger template (committed, /// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` / /// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion /// resolves + seals the recipient app's secret server-side, like a hand-declared /// email trigger). #[derive(Default)] struct Recipient { routes: bool, triggers: bool, email: bool, } fn spec_is_email(spec: &serde_json::Value) -> bool { spec.get("kind").and_then(serde_json::Value::as_str) == Some("email") } async fn app_receives_template_expansions( svc: &ApplyService, app_id: AppId, bundle: &TreeBundle, ) -> Result { let Some(app) = svc .apps .get_by_id(app_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))? else { return Ok(Recipient::default()); }; let ancestors = svc .groups .ancestors(app.group_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; let ancestor_ids: std::collections::HashSet = ancestors.iter().map(|g| g.id).collect(); let mut r = Recipient::default(); // Committed templates on any ancestor group. for gid in &ancestor_ids { if !r.routes && !crate::template_repo::list_for_group(&svc.pool, *gid) .await .map_err(|e| ApplyError::Backend(e.to_string()))? .is_empty() { r.routes = true; } for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid) .await .map_err(|e| ApplyError::Backend(e.to_string()))? { r.triggers = true; r.email = r.email || spec_is_email(&tt.spec); } } // Templates newly declared by a group node in this bundle that is an ancestor // of the app (existing groups only — a to-create group has no apps). for node in &bundle.nodes { if node.kind != NodeKind::Group { continue; } let declares_routes = !node.bundle.route_templates.is_empty(); let declares_triggers = !node.bundle.trigger_templates.is_empty(); if !declares_routes && !declares_triggers { continue; } if let Some(g) = svc .groups .get_by_slug(&node.slug) .await .map_err(|e| ApplyError::Backend(e.to_string()))? { if ancestor_ids.contains(&g.id) { r.routes = r.routes || declares_routes; r.triggers = r.triggers || declares_triggers; r.email = r.email || node .bundle .trigger_templates .iter() .any(|t| spec_is_email(&t.spec)); } } } Ok(r) } /// 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(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })), 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() } }