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>
752 lines
28 KiB
Rust
752 lines
28 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, 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());
|
||
}
|
||
}
|