Files
PiCloud/crates/manager-core/src/apply_api.rs
MechaCat02 86448beb06 fix(security): server-authoritative approval gate + auth/session/file hardening
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit.

H1 — the per-env approval gate is now server-authoritative. The governing
project is resolved from the target node's nearest-claimed ancestor
(`governing_env_policy`/`_tree` + `governing_project_id` +
`ProjectRepository::get_environments_by_id`), independent of the client-supplied
`[project]`. Omitting or spoofing the project block can no longer skip a gate the
owning project established; a to-create group resolves its declared parent's
chain so a fresh subtree node inherits the gate. Fails closed on any read error.

H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe
`rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request
task (unauthenticated per-request DoS). Regression test added.

C1 — admin sessions gain an absolute lifetime cap (migration 0070,
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch`
clamps the sliding bump to it, so a continuously-used or stolen-but-warm token
self-expires. Mirrors the data-plane app-user cap.

C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two
that return a raw credential), so a proxy/CDN/browser cache can't retain it.

B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a
valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and
group download paths now set attachment + `X-Content-Type-Options: nosniff` +
a restrictive CSP, closing a group-path stored-XSS gap.

Also folds in the server-side plan-warning plumbing (`plan_warnings`,
`PlanResult::warnings`) and the `app_only_reject` message helper that the CLI
plan-preview change builds on, plus operator security notes (reads-open shared-
topic SSE; the `--env` label is advisory, not a boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:35 +02:00

752 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<RouteTemplateInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<TriggerTemplateInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<CollectionInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, 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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, 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<Bundle>`. 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<ProjectDecl>,
}
#[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>,
/// §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<ProjectDecl>,
/// §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<String>,
/// §3 M3: environments the actor explicitly approved (`--approve <env>`). A
/// confirm-required env is refused unless it appears here; `--yes` alone does
/// NOT add it.
#[serde(default)]
pub approved_envs: Vec<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?;
// §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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<PlanRequest>,
) -> 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, &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<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<PlanRequest>,
) -> 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),
&req.bundle,
req.project.as_ref(),
)
.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?;
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<String>,
/// §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<ProjectDecl>,
#[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<String>,
/// §3 M3: environments the actor explicitly approved (`--approve <env>`).
#[serde(default)]
approved_envs: Vec<String>,
}
#[derive(Deserialize)]
struct TreePlanRequest {
// Flattened for the same bare-bundle wire compatibility as `PlanRequest`.
#[serde(flatten)]
bundle: TreeBundle,
#[serde(default)]
project: Option<ProjectDecl>,
}
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).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project.as_ref()).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)).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<String, bool>,
env: Option<&str>,
approved_envs: &[String],
) -> Result<bool, ApplyError> {
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<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?;
}
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<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(_) | 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());
}
}