feat(apply): server-side enforcement of per-env approval gating (§3 M3 / §4.2)
The per-env approval gate (`[project.environments]`, `confirm = true`) was
client-side only — the policy was `#[serde(skip_serializing)]`, so a patched or
older CLI applying to a confirm-required env bypassed it entirely. This makes
the gate server-enforced, admin-gated, and audited (the deferred §4.2 piece).
- Wire: `ProjectDecl` gains `environments` (the CLI stops skip_serializing it and
sends the selected `env` + the `--approve` set on the apply request). The
server RE-DERIVES the gate from the request (`env_gate_check`): a
confirm-required env not in `approved_envs` → 409 `ApprovalRequired`.
- Admin gate: an approved override additionally requires `AppAdmin`/`GroupAdmin`
on EVERY declared node — a step up from the editor write caps an ordinary
apply needs (reusing the admin caps per §4.2) — and emits a `tracing::info!`
audit line. Enforced on all three handlers (single app, single group, tree);
the check runs before any tx, so a denial (403) precedes any mutation.
- Token: the env policy folds into the TREE bound-plan token (a policy edit
between plan and apply trips StateMoved). The single-node token is left
unchanged (a bare live-state hash), so plan/apply still match without threading
a project into it.
- CLI: single-node `apply`/`plan` now resolve the GOVERNING project (own block
else nearest ancestor's) so a leaf apply carries the root's policy; `plan`
surfaces `approvals_required`. Client-side `require_env_approval` fast-fail
kept as UX.
Adapts the earlier feat/ownership-and-approval M5 (654e387) to main's DTOs; main
addresses tree group nodes by slug only, so the admin loop mirrors authz_tree
(app: resolve_app_id fail-closed; group: get_by_slug, skip to-create).
Threat-model note (honest scope, in the doc + code comments): this closes the
patched/older-CLI bypass and admin-gates + audits the override, but is NOT a
hermetic boundary against a hand-crafted request that OMITS the policy — the
policy is applier-supplied, not persisted server state. Full closure (persist a
`project_environments` source of truth + a server-determined env) is deferred.
Review (adversarial pass): fixed a MEDIUM (single-node `plan` didn't resolve the
governing project, so a leaf plan under-reported the gate `apply` enforces) and a
LOW (duplicate clippy attr); corrected overstated "can't bypass" wording.
Tests: ProjectDecl gate unit test; env_approval journeys
`server_enforces_the_gate_against_a_non_stock_client` (raw request → 409 without
approval, admin-approved → 200) and `approving_a_gated_apply_requires_admin`
(editor + --approve → 403). 417 lib tests + 33 CLI journeys + workspace clippy
-D warnings + fmt clean. No migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -205,12 +205,22 @@ pub struct ApplyRequest {
|
|||||||
/// refuses (409) if the app's live state has changed since.
|
/// refuses (409) if the app's live state has changed since.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub expected_token: Option<String>,
|
pub expected_token: Option<String>,
|
||||||
/// §7 ownership: the declaring repo's `[project]` (absent = no claim).
|
/// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also
|
||||||
|
/// carries the §3 M3 env approval policy (`project.environments`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project: Option<ProjectDecl>,
|
pub project: Option<ProjectDecl>,
|
||||||
/// §7 ownership: reassign a node owned by another project (group-admin).
|
/// §7 ownership: reassign a node owned by another project (group-admin).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub takeover: bool,
|
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(
|
async fn apply_handler(
|
||||||
@@ -221,6 +231,15 @@ async fn apply_handler(
|
|||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
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?;
|
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||||
|
// §3 M3: server-authoritative per-env approval gate. If the target env is
|
||||||
|
// confirm-required and approved, the override additionally requires ADMIN on
|
||||||
|
// the node (a step up from the editor write caps above) + is audited.
|
||||||
|
if env_gate_check(req.project.as_ref(), 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 {
|
let claim = OwnershipClaim {
|
||||||
project: req.project,
|
project: req.project,
|
||||||
takeover: req.takeover,
|
takeover: req.takeover,
|
||||||
@@ -298,6 +317,17 @@ async fn group_apply_handler(
|
|||||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
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)
|
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
|
||||||
.await?;
|
.await?;
|
||||||
|
// §3 M3: per-env approval gate — an approved gated apply requires GroupAdmin.
|
||||||
|
if env_gate_check(req.project.as_ref(), 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 {
|
let claim = OwnershipClaim {
|
||||||
project: req.project,
|
project: req.project,
|
||||||
takeover: req.takeover,
|
takeover: req.takeover,
|
||||||
@@ -328,6 +358,7 @@ struct TreeApplyRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
expected_token: Option<String>,
|
expected_token: Option<String>,
|
||||||
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
||||||
|
/// Also carries the §3 M3 env approval policy (`project.environments`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
project: Option<ProjectDecl>,
|
project: Option<ProjectDecl>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -336,6 +367,12 @@ struct TreeApplyRequest {
|
|||||||
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
|
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
structure_mode: StructureMode,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -364,6 +401,13 @@ async fn tree_apply_handler(
|
|||||||
Json(req): Json<TreeApplyRequest>,
|
Json(req): Json<TreeApplyRequest>,
|
||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||||
|
// §3 M3: server-authoritative per-env approval gate. On an approved gated
|
||||||
|
// apply, require ADMIN on EVERY declared node (a step up from the editor
|
||||||
|
// write caps authz_tree checks) + audit.
|
||||||
|
if env_gate_check(req.project.as_ref(), 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 {
|
let claim = OwnershipClaim {
|
||||||
project: req.project,
|
project: req.project,
|
||||||
takeover: req.takeover,
|
takeover: req.takeover,
|
||||||
@@ -381,6 +425,79 @@ async fn tree_apply_handler(
|
|||||||
Ok(Json(report))
|
Ok(Json(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §3 M3: the per-env approval gate, re-derived from the project's policy on the
|
||||||
|
/// wire (the CLI's client-side check is convenience; THIS is authoritative).
|
||||||
|
/// 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
|
||||||
|
/// project, no env, or `confirm = false`). `Err(ApprovalRequired)` when gated
|
||||||
|
/// and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
||||||
|
fn env_gate_check(
|
||||||
|
project: Option<&ProjectDecl>,
|
||||||
|
env: Option<&str>,
|
||||||
|
approved_envs: &[String],
|
||||||
|
) -> Result<bool, ApplyError> {
|
||||||
|
let (Some(project), Some(env)) = (project, env) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if !project.confirm_required(env) {
|
||||||
|
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
|
/// 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
|
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||||
/// only the per-node read cap.
|
/// only the per-node read cap.
|
||||||
@@ -551,9 +668,10 @@ impl IntoResponse for ApplyError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
json!({ "error": self.to_string() }),
|
json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
// 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a
|
||||||
// §7 ownership conflict — actionable (declare [project], --takeover).
|
// §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7
|
||||||
Self::OwnershipConflict(_) => {
|
// ownership conflict (declare `[project]`, `--takeover`).
|
||||||
|
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
|
||||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
}
|
}
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
|||||||
@@ -480,6 +480,11 @@ pub struct PlanResult {
|
|||||||
/// conflict / unclaimed). Present only when a `[project]` was declared.
|
/// conflict / unclaimed). Present only when a `[project]` was declared.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ownership: Option<OwnershipPreview>,
|
pub ownership: Option<OwnershipPreview>,
|
||||||
|
/// §3 M3: environments the governing project marks confirm-required — applying
|
||||||
|
/// to one needs `pic apply --approve <env>`. Surfaced so CI sees the gate at
|
||||||
|
/// `plan` time, before an `apply` refuses.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub approvals_required: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -539,6 +544,41 @@ pub struct ProjectDecl {
|
|||||||
/// (no ceiling).
|
/// (no ceiling).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parent_group: Option<String>,
|
pub parent_group: Option<String>,
|
||||||
|
/// §3 M3 per-env approval policy (the root manifest's `[project.environments]`).
|
||||||
|
/// A `confirm = true` env makes applying to it a gated, admin-gated + audited
|
||||||
|
/// step (`--approve <env>` required; a blanket `--yes` does NOT cover it). The
|
||||||
|
/// server re-derives the gate from THIS field — the CLI's client-side check is
|
||||||
|
/// convenience, this is the authoritative source. `#[serde(default)]` keeps a
|
||||||
|
/// pre-M3 client (which omits it) backward-compatible (no gate).
|
||||||
|
#[serde(default)]
|
||||||
|
pub environments: std::collections::BTreeMap<String, EnvPolicyDecl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3 M3: one environment's server-side apply policy (mirrors the manifest's
|
||||||
|
/// `EnvPolicy`). `confirm = true` gates applying to this env.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct EnvPolicyDecl {
|
||||||
|
#[serde(default)]
|
||||||
|
pub confirm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectDecl {
|
||||||
|
/// Does environment `env` require explicit approval to apply (§3 M3)?
|
||||||
|
#[must_use]
|
||||||
|
pub fn confirm_required(&self, env: &str) -> bool {
|
||||||
|
self.environments.get(env).is_some_and(|p| p.confirm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of every confirm-required environment, sorted (for `plan` to surface
|
||||||
|
/// so CI sees the gate before an `apply` refuses).
|
||||||
|
#[must_use]
|
||||||
|
pub fn gated_envs(&self) -> Vec<String> {
|
||||||
|
self.environments
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, p)| p.confirm)
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ownership context threaded through an apply: the declared project (if
|
/// The ownership context threaded through an apply: the declared project (if
|
||||||
@@ -664,6 +704,10 @@ pub struct NodePlan {
|
|||||||
pub struct TreePlanResult {
|
pub struct TreePlanResult {
|
||||||
pub nodes: Vec<NodePlan>,
|
pub nodes: Vec<NodePlan>,
|
||||||
pub state_token: String,
|
pub state_token: String,
|
||||||
|
/// §3 M3: environments the project marks confirm-required (see
|
||||||
|
/// [`PlanResult::approvals_required`]). Surfaced so CI sees the gate at plan.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub approvals_required: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -768,6 +812,13 @@ pub enum ApplyError {
|
|||||||
StateMoved,
|
StateMoved,
|
||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
/// §3 M3: applying to a confirm-required environment without an explicit
|
||||||
|
/// per-env approval. Maps to 409 — actionable (`--approve <env>`).
|
||||||
|
#[error(
|
||||||
|
"environment `{0}` requires explicit approval to apply; \
|
||||||
|
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
|
||||||
|
)]
|
||||||
|
ApprovalRequired(String),
|
||||||
/// §7 multi-repo ownership: this apply's `[project]` does not own a node it
|
/// §7 multi-repo ownership: this apply's `[project]` does not own a node it
|
||||||
/// touches (or the node is unclaimed and the apply declared no project).
|
/// touches (or the node is unclaimed and the apply declared no project).
|
||||||
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
|
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
|
||||||
@@ -885,6 +936,7 @@ impl ApplyService {
|
|||||||
// `apply` can refuse if the node changed underneath it (§4.2).
|
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||||
state_token: state_token_with_names(¤t, ¤t_names),
|
state_token: state_token_with_names(¤t, ¤t_names),
|
||||||
ownership,
|
ownership,
|
||||||
|
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,7 +1734,7 @@ impl ApplyService {
|
|||||||
let existing = self.load_existing_groups(bundle).await?;
|
let existing = self.load_existing_groups(bundle).await?;
|
||||||
let resolved: HashMap<String, GroupId> =
|
let resolved: HashMap<String, GroupId> =
|
||||||
existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
|
existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
|
||||||
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?;
|
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved, project).await?;
|
||||||
// §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
|
// §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
|
||||||
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
|
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
|
||||||
let pg = self.resolve_group(pg_slug).await?;
|
let pg = self.resolve_group(pg_slug).await?;
|
||||||
@@ -1720,6 +1772,7 @@ impl ApplyService {
|
|||||||
Ok(TreePlanResult {
|
Ok(TreePlanResult {
|
||||||
nodes,
|
nodes,
|
||||||
state_token: token,
|
state_token: token,
|
||||||
|
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1766,7 +1819,9 @@ impl ApplyService {
|
|||||||
structure_mode,
|
structure_mode,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?;
|
let (prepared, to_create, token) = self
|
||||||
|
.prepare_tree(bundle, &resolved, claim.project.as_ref())
|
||||||
|
.await?;
|
||||||
if !to_create.is_empty() {
|
if !to_create.is_empty() {
|
||||||
return Err(ApplyError::Backend(
|
return Err(ApplyError::Backend(
|
||||||
"internal: a group remained unresolved after create".into(),
|
"internal: a group remained unresolved after create".into(),
|
||||||
@@ -2285,6 +2340,7 @@ impl ApplyService {
|
|||||||
&self,
|
&self,
|
||||||
bundle: &'a TreeBundle,
|
bundle: &'a TreeBundle,
|
||||||
resolved_ids: &HashMap<String, GroupId>,
|
resolved_ids: &HashMap<String, GroupId>,
|
||||||
|
project: Option<&ProjectDecl>,
|
||||||
) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, String), ApplyError> {
|
) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, String), ApplyError> {
|
||||||
if bundle.nodes.is_empty() {
|
if bundle.nodes.is_empty() {
|
||||||
return Err(ApplyError::Invalid("project tree has no nodes".into()));
|
return Err(ApplyError::Invalid("project tree has no nodes".into()));
|
||||||
@@ -2437,6 +2493,19 @@ impl ApplyService {
|
|||||||
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
|
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// §3 M3: fold the env approval policy (desired state) into the token, so a
|
||||||
|
// policy edit between plan and apply trips StateMoved. Sorted key=confirm
|
||||||
|
// pairs for order-independence. Both plan_tree and apply_tree pass the
|
||||||
|
// same (root) project here, so the fold is symmetric.
|
||||||
|
if let Some(p) = project {
|
||||||
|
let mut envs: Vec<String> = p
|
||||||
|
.environments
|
||||||
|
.iter()
|
||||||
|
.map(|(name, pol)| format!("{name}={}", pol.confirm))
|
||||||
|
.collect();
|
||||||
|
envs.sort_unstable();
|
||||||
|
token_parts.push(format!("proj|{}", envs.join(",")));
|
||||||
|
}
|
||||||
token_parts.sort_unstable();
|
token_parts.sort_unstable();
|
||||||
Ok((prepared, to_create, combine_tokens(&token_parts)))
|
Ok((prepared, to_create, combine_tokens(&token_parts)))
|
||||||
}
|
}
|
||||||
@@ -6029,4 +6098,31 @@ mod tests {
|
|||||||
"conflict"
|
"conflict"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_decl_gates_only_confirm_required_envs() {
|
||||||
|
// §3 M3: the server re-derives the gate from ProjectDecl.environments.
|
||||||
|
let mut environments = std::collections::BTreeMap::new();
|
||||||
|
environments.insert("production".to_string(), EnvPolicyDecl { confirm: true });
|
||||||
|
environments.insert("staging".to_string(), EnvPolicyDecl { confirm: false });
|
||||||
|
let decl = ProjectDecl {
|
||||||
|
slug: "p".into(),
|
||||||
|
name: None,
|
||||||
|
parent_group: None,
|
||||||
|
environments,
|
||||||
|
};
|
||||||
|
assert!(decl.confirm_required("production"));
|
||||||
|
assert!(!decl.confirm_required("staging"));
|
||||||
|
assert!(!decl.confirm_required("unknown"));
|
||||||
|
assert_eq!(decl.gated_envs(), vec!["production".to_string()]);
|
||||||
|
// A project with no policy gates nothing (backward-compatible default).
|
||||||
|
let bare = ProjectDecl {
|
||||||
|
slug: "p".into(),
|
||||||
|
name: None,
|
||||||
|
parent_group: None,
|
||||||
|
environments: std::collections::BTreeMap::new(),
|
||||||
|
};
|
||||||
|
assert!(!bare.confirm_required("production"));
|
||||||
|
assert!(bare.gated_envs().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1276,6 +1276,8 @@ impl Client {
|
|||||||
project: Option<&crate::manifest::ManifestProject>,
|
project: Option<&crate::manifest::ManifestProject>,
|
||||||
takeover: bool,
|
takeover: bool,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
|
env: Option<&str>,
|
||||||
|
approved_envs: &[String],
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let slug = seg(slug);
|
let slug = seg(slug);
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
@@ -1284,6 +1286,8 @@ impl Client {
|
|||||||
"expected_token": expected_token,
|
"expected_token": expected_token,
|
||||||
"project": project,
|
"project": project,
|
||||||
"takeover": takeover,
|
"takeover": takeover,
|
||||||
|
"env": env,
|
||||||
|
"approved_envs": approved_envs,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(
|
.request(
|
||||||
@@ -1330,6 +1334,8 @@ impl Client {
|
|||||||
takeover: bool,
|
takeover: bool,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
structure_mode: &str,
|
structure_mode: &str,
|
||||||
|
env: Option<&str>,
|
||||||
|
approved_envs: &[String],
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"bundle": bundle,
|
"bundle": bundle,
|
||||||
@@ -1338,6 +1344,8 @@ impl Client {
|
|||||||
"project": project,
|
"project": project,
|
||||||
"takeover": takeover,
|
"takeover": takeover,
|
||||||
"structure_mode": structure_mode,
|
"structure_mode": structure_mode,
|
||||||
|
"env": env,
|
||||||
|
"approved_envs": approved_envs,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||||
@@ -1574,6 +1582,9 @@ pub struct PlanDto {
|
|||||||
/// §7 M3: the ownership outcome this apply would produce.
|
/// §7 M3: the ownership outcome this apply would produce.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ownership: Option<OwnershipPreviewDto>,
|
pub ownership: Option<OwnershipPreviewDto>,
|
||||||
|
/// §3 M3: envs the governing project marks confirm-required (need `--approve`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub approvals_required: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1610,6 +1621,9 @@ pub struct TreePlanDto {
|
|||||||
pub nodes: Vec<NodePlanDto>,
|
pub nodes: Vec<NodePlanDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub state_token: String,
|
pub state_token: String,
|
||||||
|
/// §3 M3: envs the project marks confirm-required (need `--approve <env>`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub approvals_required: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -68,9 +68,15 @@ pub async fn run(
|
|||||||
&slug,
|
&slug,
|
||||||
&bundle,
|
&bundle,
|
||||||
prune,
|
prune,
|
||||||
manifest.project.as_ref(),
|
// §3 M3: send the GOVERNING project (own block, else nearest ancestor's)
|
||||||
|
// so the server receives the env-approval policy and can enforce the
|
||||||
|
// gate authoritatively. It is also the correct ownership project for
|
||||||
|
// this node (a leaf applying under its repo's project).
|
||||||
|
governing.as_ref(),
|
||||||
takeover,
|
takeover,
|
||||||
expected_token.as_deref(),
|
expected_token.as_deref(),
|
||||||
|
env,
|
||||||
|
approve,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(base_dir, &slug);
|
crate::linkstate::clear_plan(base_dir, &slug);
|
||||||
@@ -172,6 +178,8 @@ pub async fn run_tree(
|
|||||||
takeover,
|
takeover,
|
||||||
expected_token.as_deref(),
|
expected_token.as_deref(),
|
||||||
structure_mode,
|
structure_mode,
|
||||||
|
env,
|
||||||
|
approve,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(dir, token_key);
|
crate::linkstate::clear_plan(dir, token_key);
|
||||||
@@ -247,7 +255,7 @@ pub async fn run_tree(
|
|||||||
/// honors the repo's env-approval gate. Loaded WITHOUT the env overlay — only
|
/// honors the repo's env-approval gate. Loaded WITHOUT the env overlay — only
|
||||||
/// the `[project]` block matters here, and `load_with_env` would require an
|
/// the `[project]` block matters here, and `load_with_env` would require an
|
||||||
/// overlay next to every ancestor and spuriously skip the governing manifest.
|
/// overlay next to every ancestor and spuriously skip the governing manifest.
|
||||||
fn find_governing_project(manifest_path: &Path) -> Option<ManifestProject> {
|
pub(crate) fn find_governing_project(manifest_path: &Path) -> Option<ManifestProject> {
|
||||||
let dir = manifest_path.parent()?;
|
let dir = manifest_path.parent()?;
|
||||||
dir.ancestors().skip(1).find_map(|anc| {
|
dir.ancestors().skip(1).find_map(|anc| {
|
||||||
let candidate = anc.join(crate::manifest::MANIFEST_FILE);
|
let candidate = anc.join(crate::manifest::MANIFEST_FILE);
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
|||||||
NodeKind::App
|
NodeKind::App
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// §3 M3: preview the gate against the GOVERNING project (own block, else the
|
||||||
|
// nearest ancestor's), symmetric with `apply::run` — so `pic plan --file` on
|
||||||
|
// a leaf still surfaces `approvals_required` for the gate the apply enforces.
|
||||||
|
let governing = manifest
|
||||||
|
.project
|
||||||
|
.clone()
|
||||||
|
.or_else(|| crate::cmds::apply::find_governing_project(manifest_path));
|
||||||
let plan = client
|
let plan = client
|
||||||
.plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref())
|
.plan_node(kind, manifest.slug(), &bundle, governing.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||||
// node changing underneath the reviewed plan (best-effort — a read-only
|
// node changing underneath the reviewed plan (best-effort — a read-only
|
||||||
@@ -122,6 +129,18 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
|
render_approvals(&plan.approvals_required, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3 M3: surface the confirm-required environments so CI sees the gate at plan
|
||||||
|
/// time, before an `apply --approve <env>` is needed.
|
||||||
|
fn render_approvals(approvals_required: &[String], mode: OutputMode) {
|
||||||
|
if mode != OutputMode::Json && !approvals_required.is_empty() {
|
||||||
|
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
|
||||||
|
for e in approvals_required {
|
||||||
|
eprintln!(" - {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||||
@@ -257,4 +276,5 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
|
render_approvals(&plan.approvals_required, mode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,15 @@ pub struct ManifestProject {
|
|||||||
/// §3 M3 per-env approval policy: `[project.environments]` maps an env name
|
/// §3 M3 per-env approval policy: `[project.environments]` maps an env name
|
||||||
/// to its policy. A `confirm = true` env requires an explicit `--approve
|
/// to its policy. A `confirm = true` env requires an explicit `--approve
|
||||||
/// <env>` on `pic apply --env <env>` (a blanket `--yes` does NOT cover it —
|
/// <env>` on `pic apply --env <env>` (a blanket `--yes` does NOT cover it —
|
||||||
/// §4.2 "CI must opt in per environment"). Client-side gating only; never
|
/// §4.2 "CI must opt in per environment"). The CLI gates client-side (fast
|
||||||
/// sent to the server.
|
/// fail) AND sends the policy to the server, which re-derives the gate from
|
||||||
#[serde(default, skip_serializing)]
|
/// the request and enforces it (admin-gated on every declared node + audited).
|
||||||
|
/// This closes the "patched/older CLI that still declares the project but
|
||||||
|
/// skips the local check" bypass; it is NOT a hermetic boundary against a
|
||||||
|
/// request that omits the policy entirely (the policy lives in the applier's
|
||||||
|
/// manifest, not persisted server state — full closure would persist it).
|
||||||
|
/// Sent only when non-empty (pre-M3 wire otherwise).
|
||||||
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
pub environments: std::collections::BTreeMap<String, EnvPolicy>,
|
pub environments: std::collections::BTreeMap<String, EnvPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
|
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
|
||||||
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
|
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
|
||||||
//! opt in per environment"). An unlisted or `confirm = false` env applies
|
//! opt in per environment"). An unlisted or `confirm = false` env applies
|
||||||
//! freely. The gate is client-side, so a refused apply never reaches the server.
|
//! freely. The CLI gates client-side (fast fail) AND the SERVER re-derives the
|
||||||
|
//! policy from the request and enforces it — a confirm-required apply is refused
|
||||||
|
//! (409) unless approved, and an approved override additionally requires ADMIN on
|
||||||
|
//! every declared node (a step up from editor write caps) and is audited. This
|
||||||
|
//! closes the patched/older-CLI bypass (a client that still declares the project
|
||||||
|
//! but skips the local check); a request that omits the policy is inherently
|
||||||
|
//! ungated (the policy is applier-supplied, not persisted server state).
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -13,6 +19,7 @@ use tempfile::TempDir;
|
|||||||
|
|
||||||
use crate::common;
|
use crate::common;
|
||||||
use crate::common::cleanup::GroupGuard;
|
use crate::common::cleanup::GroupGuard;
|
||||||
|
use crate::common::member;
|
||||||
|
|
||||||
/// A repo whose `[project]` gates `production` (confirm-required) but not
|
/// A repo whose `[project]` gates `production` (confirm-required) but not
|
||||||
/// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files
|
/// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files
|
||||||
@@ -204,3 +211,129 @@ fn leaf_file_apply_honors_the_root_env_gate() {
|
|||||||
.assert()
|
.assert()
|
||||||
.success();
|
.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn server_enforces_the_gate_against_a_non_stock_client() {
|
||||||
|
// The client-side gate is a convenience; a patched CLI or a raw request can
|
||||||
|
// skip it. The server re-derives the policy from the wire and enforces it:
|
||||||
|
// a raw tree apply to a confirm-required env WITHOUT the env in
|
||||||
|
// `approved_envs` is refused (409), regardless of the client.
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("eas-grp");
|
||||||
|
let project = common::unique_slug("eas-proj");
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let http = reqwest::blocking::Client::new();
|
||||||
|
let body = |approved: serde_json::Value| {
|
||||||
|
serde_json::json!({
|
||||||
|
"bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
|
||||||
|
"project": {
|
||||||
|
"slug": project,
|
||||||
|
"environments": { "production": { "confirm": true } }
|
||||||
|
},
|
||||||
|
"env": "production",
|
||||||
|
"approved_envs": approved,
|
||||||
|
"prune": false,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// No approval on the wire → 409 ApprovalRequired (server-enforced, the CLI's
|
||||||
|
// client-side gate never ran).
|
||||||
|
let resp = http
|
||||||
|
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||||||
|
.bearer_auth(&env.token)
|
||||||
|
.json(&body(serde_json::json!([])))
|
||||||
|
.send()
|
||||||
|
.expect("tree apply");
|
||||||
|
assert_eq!(
|
||||||
|
resp.status().as_u16(),
|
||||||
|
409,
|
||||||
|
"a raw gated apply without approval must be refused server-side (got {}: {})",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Approved on the wire + an admin token (holds GroupAdmin) → succeeds.
|
||||||
|
let ok = http
|
||||||
|
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||||||
|
.bearer_auth(&env.token)
|
||||||
|
.json(&body(serde_json::json!(["production"])))
|
||||||
|
.send()
|
||||||
|
.expect("tree apply");
|
||||||
|
assert!(
|
||||||
|
ok.status().is_success(),
|
||||||
|
"an approved gated apply by an admin must succeed (got {}: {})",
|
||||||
|
ok.status(),
|
||||||
|
ok.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn approving_a_gated_apply_requires_admin() {
|
||||||
|
// §4.2: overriding a gate is ADMIN authority — a step up from the editor
|
||||||
|
// write caps an ordinary apply needs. A group `editor` who CAN write the
|
||||||
|
// group still cannot approve a gated apply of it.
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("eaa-grp");
|
||||||
|
let project = common::unique_slug("eaa-proj");
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// A member granted `editor` (write, not admin) on the group.
|
||||||
|
let m = member::member_user(fx, &common::unique_username("eaa"));
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"groups", "members", "add", &group, &m.id, "--role", "editor",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
// A gated repo managing that group (with a var, so the editor's write cap is
|
||||||
|
// exercised — proving the admin gate is ABOVE it). Applied by the editor.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("picloud.toml"),
|
||||||
|
format!(
|
||||||
|
"[project]\nslug = \"{project}\"\n\n\
|
||||||
|
[project.environments]\nproduction = {{ confirm = true }}\n\n\
|
||||||
|
[group]\nslug = \"{group}\"\nname = \"Gated\"\n\n\
|
||||||
|
[vars]\nregion = \"eu\"\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();
|
||||||
|
let manifest = dir.path().join("picloud.toml");
|
||||||
|
|
||||||
|
let out = common::pic_as(&member_env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&manifest)
|
||||||
|
.args(["--env", "production", "--approve", "production"])
|
||||||
|
.output()
|
||||||
|
.expect("apply");
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"a non-admin editor must not be able to approve a gated apply"
|
||||||
|
);
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||||
|
assert!(
|
||||||
|
err.contains("forbidden") || err.contains("403"),
|
||||||
|
"the approval denial should be an authz error:\n{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -827,14 +827,29 @@ divergence (`divergence_preview` → a `structure` row: `diverged`, server + man
|
|||||||
token folds `structure_version`). Pinned by `tests/structural_divergence.rs`.
|
token folds `structure_version`). Pinned by `tests/structural_divergence.rs`.
|
||||||
|
|
||||||
**§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the
|
**§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the
|
||||||
root `[project]` maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, `#[serde(skip_serializing)]`
|
root `[project]` maps an env name to `{ confirm = bool }`. `pic apply --env <e>` is REFUSED (client-side fast-fail,
|
||||||
— purely CLI-side). `pic apply --env <e>` is REFUSED (client-side, before any request) when `<e>` is confirm-required
|
before any request) when `<e>` is confirm-required unless it is explicitly `--approve <e>`d; a blanket `--yes` does
|
||||||
unless it is explicitly `--approve <e>`d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per
|
NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted / `confirm = false` env applies freely.
|
||||||
environment"); an unlisted / `confirm = false` env applies freely. `require_env_approval` gates both the single
|
`require_env_approval` gates both the single (`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a
|
||||||
(`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a repeatable flag. Value overlays
|
repeatable flag. Value overlays (`picloud.<env>.toml`) already merge client-side, so this milestone is the
|
||||||
(`picloud.<env>.toml`) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by
|
confirm-policy gate only. Pinned by `tests/env_approval.rs`.
|
||||||
`tests/env_approval.rs`. **Deferred (documented, not built):** server-side audited approval-override capability
|
|
||||||
(§4.2 → `authz::can`); env-as-app mapping + declarative server-side `@E` config scoping.
|
**Server-side enforcement shipped (2026-07-11).** The policy is no longer `skip_serializing` — the CLI SENDS
|
||||||
|
`[project.environments]` (on `ProjectDecl.environments`) plus the selected `--env` + the `--approve` set, and the
|
||||||
|
server RE-DERIVES the gate authoritatively (`env_gate_check` in `apply_api.rs`): a confirm-required env not in
|
||||||
|
`approved_envs` → **409 `ApprovalRequired`**; an approved override additionally requires **`AppAdmin`/`GroupAdmin`
|
||||||
|
on every declared node** (a step up from the editor write caps an ordinary apply needs — §4.2 "override a gate is
|
||||||
|
its own capability", reusing the admin caps) and emits a `tracing::info!` **audit** line. Enforced on all three
|
||||||
|
handlers (single app, single group, tree); the env policy folds into the **tree** bound-plan token (a policy edit
|
||||||
|
between plan and apply trips `StateMoved`); single-node `apply --file` now sends the GOVERNING project (own block
|
||||||
|
else nearest ancestor's), so a leaf apply carries the root's policy; `plan` surfaces `approvals_required`. Pinned by
|
||||||
|
`tests/env_approval.rs::{server_enforces_the_gate_against_a_non_stock_client, approving_a_gated_apply_requires_admin}`.
|
||||||
|
**Threat-model note (honest scope):** this closes the "patched/older CLI that still declares the project but skips
|
||||||
|
the local check" bypass, and admin-gates + audits the override. It is NOT a hermetic authorization boundary against
|
||||||
|
a hand-crafted request that OMITS the policy (or the `env` label) — the policy is applier-supplied, not persisted
|
||||||
|
server state, so an omitted policy is inherently ungated. **Full closure (deferred):** persist the env policy
|
||||||
|
server-side (a `project_environments` source of truth) + a server-determined env, so enforcement is independent of
|
||||||
|
what the request declares. **Also deferred:** env-as-app mapping + declarative server-side `@E` config scoping.
|
||||||
|
|
||||||
**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**
|
**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user