Files
PiCloud/crates/manager-core/src/apply_api.rs
MechaCat02 c04c684a2e feat(hierarchies): cross-repo template expansion (M7)
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.

- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
  ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
  group not already handled in Phase B. All descendant locks are taken up
  front in the single sorted batch (their union is invariant under reparenting
  in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
  Per-recipient write caps (AppWriteRoute / AppManageTriggers /
  AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
  post-reparent, already-locked app — checked set == written set, no TOCTOU.
  expand_*_templates_tx are now self-contained (load hand-declared identities
  from the tx), so collisions are caught on descendants too. Blast radius now
  counts the true DB subtree. Descendant expansion errors are tagged with the
  app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
  in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
  expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:38:33 +02:00

704 lines
26 KiB
Rust

//! 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, 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<String>,
}
async fn apply_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, 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<String>,
}
#[derive(Deserialize)]
struct TreeApplyRequest {
bundle: TreeBundle,
#[serde(default)]
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// 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<String>,
/// 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<String>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, 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<bool>,
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<Recipient, ApplyError> {
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<GroupId> = 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<GroupId, ApplyError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
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<AppId, ApplyError> {
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()
}
}