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:
@@ -318,6 +318,11 @@ table: outbox
|
||||
claimed_by: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: project_environments
|
||||
project_id: uuid NOT NULL
|
||||
env_name: text NOT NULL
|
||||
confirm: boolean NOT NULL
|
||||
|
||||
table: projects
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
slug: text NOT NULL
|
||||
@@ -603,6 +608,9 @@ indexes on outbox:
|
||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||
outbox_pkey: public.outbox USING btree (id)
|
||||
|
||||
indexes on project_environments:
|
||||
project_environments_pkey: public.project_environments USING btree (project_id, env_name)
|
||||
|
||||
indexes on projects:
|
||||
projects_pkey: public.projects USING btree (id)
|
||||
projects_slug_key: public.projects USING btree (slug)
|
||||
@@ -847,6 +855,10 @@ constraints on outbox:
|
||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on project_environments:
|
||||
[FOREIGN KEY] project_environments_project_id_fkey: FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] project_environments_pkey: PRIMARY KEY (project_id, env_name)
|
||||
|
||||
constraints on projects:
|
||||
[FOREIGN KEY] projects_created_by_fkey: FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
|
||||
@@ -992,3 +1004,4 @@ constraints on vars:
|
||||
0064: shared topic queue kinds
|
||||
0065: group queues
|
||||
0066: projects
|
||||
0067: project environments
|
||||
|
||||
@@ -104,3 +104,47 @@ async fn list_with_owner_and_ancestors_surface_ownership() {
|
||||
.expect("get_by_id");
|
||||
assert_eq!(by_id.map(|p| p.slug), Some(proj_slug));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_environments_by_slug_returns_persisted_policy() {
|
||||
// §3 M3 hermetic gate: the persisted per-env policy round-trips by project
|
||||
// slug. Backs `ApplyService::effective_env_policy` (the `persisted` half).
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let projects = PostgresProjectRepository::new(pool.clone());
|
||||
|
||||
let proj_slug = uniq("penv");
|
||||
let (proj_id,): (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO projects (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(&proj_slug)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("insert project");
|
||||
for (env, confirm) in [("production", true), ("staging", false)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO project_environments (project_id, env_name, confirm) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(proj_id)
|
||||
.bind(env)
|
||||
.bind(confirm)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert env policy");
|
||||
}
|
||||
|
||||
let policy = projects
|
||||
.get_environments_by_slug(&proj_slug)
|
||||
.await
|
||||
.expect("get_environments_by_slug");
|
||||
assert_eq!(policy.get("production"), Some(&true));
|
||||
assert_eq!(policy.get("staging"), Some(&false));
|
||||
assert_eq!(policy.len(), 2);
|
||||
|
||||
// An unknown slug yields an empty policy (no gate).
|
||||
let empty = projects
|
||||
.get_environments_by_slug("no-such-project")
|
||||
.await
|
||||
.expect("get_environments_by_slug");
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user