feat(apply): make the per-env approval gate hermetic (§3 M3 / Track A M1)
The per-env approval policy was applier-supplied — a hand-crafted request
that omitted `project.environments` was ungated, and flipping a gate to
`confirm = false` in the same request un-gated it. Persist the policy
server-side and enforce against `persisted ∪ declared`.
- Migration 0067: `project_environments (project_id, env_name, confirm)`,
CASCADE on the project. Written declaratively (delete-then-insert) inside
`upsert_project_tx`, same tx as the project row + node claim.
- `ProjectRepository::get_environments_by_slug` (read side) +
`ApplyService::effective_env_policy` union the persisted policy with the
request's declared one (confirm if EITHER says so — monotonic).
- `env_gate_check` now evaluates the effective policy; the three handlers load
it before the gate. `plan.approvals_required` is the effective gated set, so
CI sees a persisted gate even when the manifest omits it.
- The union rule closes both bypasses: an omitted policy still trips a
persisted gate, and an ungating apply must itself pass the gate (the
declarative replace then takes effect next time) — TOCTOU-safe.
Pinned by env_approval::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates} +
projects_repo::get_environments_by_slug_returns_persisted_policy. Schema golden
reblessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,10 +231,13 @@ async fn apply_handler(
|
||||
) -> 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. 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)? {
|
||||
// §3 M3: server-authoritative per-env approval gate, evaluated against the
|
||||
// persisted ∪ declared policy (hermetic — an omitted policy can't bypass a
|
||||
// gate a prior apply 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.effective_env_policy(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)?;
|
||||
@@ -317,8 +320,10 @@ async fn group_apply_handler(
|
||||
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 — an approved gated apply requires GroupAdmin.
|
||||
if env_gate_check(req.project.as_ref(), req.env.as_deref(), &req.approved_envs)? {
|
||||
// §3 M3: per-env approval gate (persisted ∪ declared) — an approved gated
|
||||
// apply requires GroupAdmin.
|
||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
||||
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
@@ -401,10 +406,11 @@ async fn tree_apply_handler(
|
||||
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. 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)? {
|
||||
// §3 M3: server-authoritative per-env approval gate (persisted ∪ declared).
|
||||
// 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.effective_env_policy(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());
|
||||
}
|
||||
@@ -425,21 +431,23 @@ async fn tree_apply_handler(
|
||||
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).
|
||||
/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
|
||||
/// EFFECTIVE policy (`persisted ∪ declared`, built by
|
||||
/// `ApplyService::effective_env_policy`) — the persisted side is authoritative,
|
||||
/// so a request that omits or weakens the policy can't bypass a gate a prior
|
||||
/// apply 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(
|
||||
project: Option<&ProjectDecl>,
|
||||
policy: &std::collections::BTreeMap<String, bool>,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<bool, ApplyError> {
|
||||
let (Some(project), Some(env)) = (project, env) else {
|
||||
let Some(env) = env else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !project.confirm_required(env) {
|
||||
if !policy.get(env).copied().unwrap_or(false) {
|
||||
return Ok(false);
|
||||
}
|
||||
if approved_envs.iter().any(|e| e == env) {
|
||||
|
||||
@@ -895,6 +895,49 @@ impl ApplyService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an
|
||||
/// apply/plan — `env_name -> confirm`, computed as `persisted ∪ declared`
|
||||
/// (an env is confirm-required if EITHER the stored policy OR the request's
|
||||
/// `[project.environments]` marks it so). Loading the persisted side is what
|
||||
/// makes the gate hermetic: a request that omits (or flips-to-false) a policy
|
||||
/// a prior apply persisted still trips the gate. Empty when no project is
|
||||
/// declared. A persisted-read failure surfaces as `Backend` (fail-closed —
|
||||
/// we never silently drop a gate).
|
||||
pub async fn effective_env_policy(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let Some(project) = project else {
|
||||
return Ok(std::collections::BTreeMap::new());
|
||||
};
|
||||
let mut effective = self
|
||||
.projects
|
||||
.get_environments_by_slug(&project.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
for (env, pol) in &project.environments {
|
||||
// Union on confirm: a declared gate strengthens, never weakens.
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// The sorted names of every effectively-gated env (`effective_env_policy`
|
||||
/// filtered to `confirm`), surfaced as `plan.approvals_required`.
|
||||
async fn effective_gated_envs(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<Vec<String>, ApplyError> {
|
||||
let policy = self.effective_env_policy(project).await?;
|
||||
let mut gated: Vec<String> = policy
|
||||
.into_iter()
|
||||
.filter_map(|(env, confirm)| confirm.then_some(env))
|
||||
.collect();
|
||||
gated.sort_unstable();
|
||||
Ok(gated)
|
||||
}
|
||||
|
||||
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
||||
/// A group node has only scripts + vars — routes/triggers are rejected, and
|
||||
/// the app-only inherited-target + route-host validation is skipped.
|
||||
@@ -936,7 +979,7 @@ impl ApplyService {
|
||||
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||
state_token: state_token_with_names(¤t, ¤t_names),
|
||||
ownership,
|
||||
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1772,7 +1815,7 @@ impl ApplyService {
|
||||
Ok(TreePlanResult {
|
||||
nodes,
|
||||
state_token: token,
|
||||
approvals_required: project.map(ProjectDecl::gated_envs).unwrap_or_default(),
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4072,6 +4115,28 @@ async fn upsert_project_tx(
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
// §3 M3 (hermetic gate): persist the declared per-env approval policy as the
|
||||
// authoritative source. Declarative replace (delete-then-insert) so a
|
||||
// removed env drops out — the gate reads `persisted ∪ declared`, so an
|
||||
// ungating still has to pass the prior gate at apply time (see
|
||||
// `apply_api::env_gate_check`). Same tx as the project row + node claim.
|
||||
sqlx::query("DELETE FROM project_environments WHERE project_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
for (env_name, pol) in &decl.environments {
|
||||
sqlx::query(
|
||||
"INSERT INTO project_environments (project_id, env_name, confirm) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(env_name)
|
||||
.bind(pol.confirm)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
}
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ pub trait ProjectRepository: Send + Sync {
|
||||
async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError>;
|
||||
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>;
|
||||
/// §3 M3 (hermetic gate): the persisted per-env approval policy for the
|
||||
/// project with this slug — `env_name -> confirm`. Empty when the project is
|
||||
/// unregistered or declares no gated envs. The apply/plan path unions this
|
||||
/// with the request's declared policy so an omitted policy can't bypass a
|
||||
/// gate a prior apply persisted (see `apply_api::env_gate_check`).
|
||||
async fn get_environments_by_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresProjectRepository {
|
||||
@@ -86,6 +95,22 @@ impl ProjectRepository for PostgresProjectRepository {
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_environments_by_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, (String, bool)>(
|
||||
"SELECT pe.env_name, pe.confirm \
|
||||
FROM project_environments pe \
|
||||
JOIN projects p ON p.id = pe.project_id \
|
||||
WHERE p.slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
Reference in New Issue
Block a user