//! 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::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, 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("/tree/plan", post(tree_plan_handler)) .route("/tree/apply", post(tree_apply_handler)) .with_state(service) } #[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 TreeApplyRequest { bundle: TreeBundle, #[serde(default)] prune: bool, #[serde(default)] expected_token: Option, } async fn tree_plan_handler( State(svc): State, Extension(principal): Extension, Json(bundle): Json, ) -> Result, ApplyError> { authz_tree(&svc, &principal, &bundle, None).await?; Ok(Json(svc.plan_tree(&bundle).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?; let report = svc .apply_tree( &req.bundle, req.prune, principal.user_id, req.expected_token.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. 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 => { let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?; match apply_prune { Some(prune) => { require_group_node_writes(svc, 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(()) } /// 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> { if prune || !bundle.scripts.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(()) } /// 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::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() } }