From f4de3326332b1dde24a3458af7e952aec0eea487 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 16:59:38 +0300 Subject: [PATCH 01/12] fix(setup): withdraw a declined vault's grants, not just its entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends enablement to Vault across AWS, GCP and Azure Terraform plus CloudFormation. On AWS and GCP the vault owns no block at all — those stores are name prefixes rather than resources — so its IAM grants are the only thing it can gate, and they had been left standing. That is not cosmetic. The grant is a data-plane read (ssm:GetParameter, roles/secretmanager.secretAccessor) over a name-prefix wildcard, and resource ids allow interior hyphens, so vaults `app` and `app-config` coexist happily. Declining `app` while leaving `-app-*` granted leaves the runtime role able to read `app-config`'s secrets. Saying no has to withdraw the permission, not just the registration entry. On GCP the fix needed a second emitter. The service account's profile loop emits a byte-identical copy of the same grant, which the generator had been collapsing by body-identity dedup. Gating the vault alone made the two bodies differ, dedup stopped firing, and two ungated copies survived — holding exactly the access the deployer had declined. So the service account emitter is gated too, via gate_bindings. The shared project-wide custom roles are deliberately excluded: they already carry their own var.gcp_manage_custom_roles count, a second count is a hard conflict, and tying them to one vault's gate would delete roles that vault's siblings still hold. Azure is the one cloud where the vault is a real resource, and every role assignment names it twice — once as `scope`, once inside the uuidv5 seed that derives the assignment's name. The seed is a raw interpolation, so it is the reference most likely to be left behind. The tests assert the property rather than the mechanism. Checking that the vault's own policy carries the gate is the assertion that missed the duplicate grant: it passes straight over an ungated twin. So they resolve the template with the gate off and ask what access is still standing. --- .../src/emitters/aws/vault.rs | 8 + .../alien-cloudformation/tests/generator.rs | 2 +- .../tests/generator/enabled_queue_tests.rs | 278 -------- .../generator/enabled_vault_queue_tests.rs | 494 ++++++++++++++ ..._tests__enabled_gated_queue_and_vault.snap | 277 ++++++++ .../alien-terraform/src/emitters/aws/vault.rs | 25 + .../src/emitters/azure/vault.rs | 80 ++- .../alien-terraform/src/emitters/gcp/vault.rs | 23 +- crates/alien-terraform/tests/generator.rs | 2 +- .../tests/generator/enabled_queue_tests.rs | 330 --------- .../generator/enabled_vault_queue_tests.rs | 628 ++++++++++++++++++ 11 files changed, 1516 insertions(+), 631 deletions(-) delete mode 100644 crates/alien-cloudformation/tests/generator/enabled_queue_tests.rs create mode 100644 crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs create mode 100644 crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap delete mode 100644 crates/alien-terraform/tests/generator/enabled_queue_tests.rs create mode 100644 crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs diff --git a/crates/alien-cloudformation/src/emitters/aws/vault.rs b/crates/alien-cloudformation/src/emitters/aws/vault.rs index dbd3b20f7..5af37510d 100644 --- a/crates/alien-cloudformation/src/emitters/aws/vault.rs +++ b/crates/alien-cloudformation/src/emitters/aws/vault.rs @@ -76,6 +76,14 @@ impl CfEmitter for AwsVaultEmitter { ])) } + /// The vault is a Parameter Store name prefix, so the only resources this + /// emitter returns are the IAM policies granting access to it. The generator + /// stamps the gate's `Condition` onto each of them, so declining the vault + /// also withholds the grants. + fn supports_enabled_when(&self) -> bool { + true + } + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let vault = resource_config::(ctx, Vault::RESOURCE_TYPE)?; Ok(Some(CfExpression::object([ diff --git a/crates/alien-cloudformation/tests/generator.rs b/crates/alien-cloudformation/tests/generator.rs index 7b97a68ae..d1631dabf 100644 --- a/crates/alien-cloudformation/tests/generator.rs +++ b/crates/alien-cloudformation/tests/generator.rs @@ -15,9 +15,9 @@ mod generator { pub mod aws_email_tests; pub mod aws_full_stack_tests; pub mod aws_open_search_tests; - pub mod enabled_queue_tests; pub mod enabled_storage_tests; pub mod enabled_tests; + pub mod enabled_vault_queue_tests; pub mod kubernetes_cluster_tests; pub mod network_tests; pub mod output_chunking_tests; diff --git a/crates/alien-cloudformation/tests/generator/enabled_queue_tests.rs b/crates/alien-cloudformation/tests/generator/enabled_queue_tests.rs deleted file mode 100644 index d5853453f..000000000 --- a/crates/alien-cloudformation/tests/generator/enabled_queue_tests.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! `.enabled(input)` for `Queue` on CloudFormation. -//! -//! `enabled_tests.rs` covers the mechanics on `Kv`. A queue is where the -//! registration payload gets exercised against a stack that mixes a gated -//! resource with an ungated neighbour, on both of the paths that publish it. - -use super::helpers::{ - custom_resource_registration, gate_input, render_built_ins_template, resolve, Declined, -}; -use alien_cloudformation::{CfExpression, CfTemplate, CloudFormationTarget, RegistrationMode}; -use alien_core::{ - PermissionProfile, Queue, ResourceLifecycle, ServiceAccount, Stack, StackBuilder, - StackInputDefinition, StackSettings, -}; -use std::collections::HashMap; - -fn render_as(stack: &Stack, registration: RegistrationMode, description: &str) -> CfTemplate { - render_built_ins_template( - stack, - StackSettings::default(), - registration, - CloudFormationTarget::Aws, - "aws", - description, - ) - .0 -} - -fn render(stack: &Stack, description: &str) -> CfTemplate { - render_as(stack, custom_resource_registration(), description) -} - -const QUEUE_CONDITION: &str = "InputQueueEnabledIsTrue"; - -fn gate_inputs() -> Vec { - vec![gate_input( - "queueEnabled", - "queue", - "Whether to create the queue.", - )] -} - -/// The service account gives the "off" answer an ungated neighbour to leave -/// behind, so the payload assertions say something about which entries survive -/// rather than about the payload being empty. -fn base() -> StackBuilder { - Stack::new("gated-stack".to_string()) - .inputs(gate_inputs()) - .permission( - "execution", - PermissionProfile::new().resource("jobs", ["queue/data-write"]), - ) - .add( - ServiceAccount::new("execution-sa".to_string()).build(), - ResourceLifecycle::Frozen, - ) -} - -fn stack(gated: bool) -> Stack { - let queue = Queue::new("jobs".to_string()).build(); - let builder = base(); - if gated { - builder - .add_enabled_when(queue, ResourceLifecycle::Frozen, "queueEnabled") - .build() - } else { - builder.add(queue, ResourceLifecycle::Frozen).build() - } -} - -fn registration_entry_ids(template: &CfTemplate, conditions: &HashMap<&str, bool>) -> Vec { - let payload = template - .resources - .get("DeploymentRegistration") - .expect("registration custom resource") - .properties - .get("Resources") - .expect("registration resource list") - .clone(); - let CfExpression::List(entries) = resolve(&payload, conditions, Declined::Removed) - .expect("payload survives condition resolution") - else { - panic!("registration payload should resolve to a list"); - }; - entries - .iter() - .map(|entry| { - let CfExpression::Object(fields) = entry else { - panic!("registration entry should be an object: {entry:?}"); - }; - match fields.get("id").expect("registration entry id") { - CfExpression::String(id) => id.clone(), - other => panic!("registration entry id should be a string: {other:?}"), - } - }) - .collect() -} - -/// The `DeploymentResources` output, resolved and parsed the way the registering -/// consumer reads it. -/// -/// Returns the raw text alongside the parsed value so a caller can assert on the -/// exact bytes CloudFormation would hand over, not just on what survived -/// `serde_json`. -fn resolved_outputs_payload( - template: &CfTemplate, - conditions: &HashMap<&str, bool>, -) -> (String, serde_json::Value) { - let value = &template - .outputs - .get("DeploymentResources") - .expect("outputs fallback should carry the resources payload") - .value; - let resolved = resolve(value, conditions, Declined::Removed) - .expect("the resources output survives condition resolution"); - let CfExpression::String(text) = resolved else { - panic!("the resources output should resolve to JSON text: {resolved:?}"); - }; - let parsed = serde_json::from_str(&text) - .unwrap_or_else(|error| panic!("resources output should be valid JSON: {error}\n{text}")); - (text, parsed) -} - -/// Extract ids from the Outputs payload, verifying that every entry is a -/// well-formed object. -fn outputs_entry_ids(template: &CfTemplate, conditions: &HashMap<&str, bool>) -> Vec { - let (text, parsed) = resolved_outputs_payload(template, conditions); - let serde_json::Value::Array(entries) = parsed else { - panic!("resources output should be a JSON array: {text}"); - }; - entries - .iter() - .map(|entry| { - let id = entry - .get("id") - .unwrap_or_else(|| panic!("registration entry has no id: {entry}\n{text}")); - id.as_str() - .unwrap_or_else(|| panic!("registration entry id should be a string: {id}")) - .to_string() - }) - .collect() -} - -/// Logical ids of every resource carrying `condition`. -fn resources_conditioned_on(template: &CfTemplate, condition: &str) -> Vec { - let mut ids = template - .resources - .iter() - .filter(|(_, resource)| resource.condition.as_deref() == Some(condition)) - .map(|(logical_id, _)| logical_id.clone()) - .collect::>(); - ids.sort(); - ids -} - -#[test] -fn a_gated_queue_is_created_only_when_the_deployer_says_yes() { - let template = render(&stack(true), "gated queue"); - - let queue = template - .resources - .get("Jobs") - .expect("gated queue should still be declared"); - assert_eq!(queue.resource_type, "AWS::SQS::Queue"); - assert_eq!(queue.condition.as_deref(), Some(QUEUE_CONDITION)); - assert_eq!( - resources_conditioned_on(&template, QUEUE_CONDITION), - vec![ - "Jobs".to_string(), - "JobsExecutionSaRoleQueuePermission00".to_string(), - ], - "the queue owns the SQS queue and its resource-scoped grant, both gated" - ); -} - -/// Registration runs the typed importer over every entry it receives, so a -/// declined resource must be absent, not present-and-null. -#[test] -fn declined_resources_leave_no_registration_entry() { - let template = render(&stack(true), "gated queue"); - - let queue_on = HashMap::from([(QUEUE_CONDITION, true)]); - assert_eq!( - registration_entry_ids(&template, &queue_on), - vec!["execution-sa".to_string(), "jobs".to_string()], - "everything registers when the deployer says yes" - ); - - let queue_off = HashMap::from([(QUEUE_CONDITION, false)]); - assert_eq!( - registration_entry_ids(&template, &queue_off), - vec!["execution-sa".to_string()], - "declined resources must be absent from the payload, not null" - ); -} - -/// The same invariant on the Outputs fallback, which is the path `alien render` -/// picks whenever no notification lambda is configured. -/// -/// It carries the payload as JSON text rather than as a list, and the intrinsic -/// that produces that text renders a declined entry as a literal `null` instead -/// of dropping it. Nothing about that is visible in the rendered template — it -/// only happens once CloudFormation resolves the conditions — so this asserts on -/// the resolved text. -#[test] -fn the_outputs_fallback_omits_declined_resources() { - let template = render_as( - &stack(true), - RegistrationMode::OutputsFallback, - "gated queue, outputs fallback", - ); - - let queue_on = HashMap::from([(QUEUE_CONDITION, true)]); - assert_eq!( - outputs_entry_ids(&template, &queue_on), - vec!["execution-sa".to_string(), "jobs".to_string()], - "everything registers when the deployer says yes" - ); - - let queue_off = HashMap::from([(QUEUE_CONDITION, false)]); - let (text, _) = resolved_outputs_payload(&template, &queue_off); - assert!( - !text.contains("null"), - "a declined resource must leave nothing behind, but the payload still \ - carries a null: {text}" - ); - assert_eq!( - outputs_entry_ids(&template, &queue_off), - vec!["execution-sa".to_string()], - "only the ungated resource registers" - ); -} - -/// Declining every gated resource must still leave a payload the consumer can -/// read, rather than a malformed array or a stray delimiter. -#[test] -fn the_outputs_fallback_survives_every_resource_being_declined() { - let template = render_as( - &Stack::new("gated-stack".to_string()) - .inputs(gate_inputs()) - .add_enabled_when( - Queue::new("jobs".to_string()).build(), - ResourceLifecycle::Frozen, - "queueEnabled", - ) - .build(), - RegistrationMode::OutputsFallback, - "every resource gated, outputs fallback", - ); - - let all_off = HashMap::from([(QUEUE_CONDITION, false)]); - let (text, parsed) = resolved_outputs_payload(&template, &all_off); - assert_eq!(text, "[]", "an all-declined payload is an empty array"); - assert_eq!(parsed, serde_json::json!([])); -} - -/// Ungated stacks gain no conditions: opt-in means no `.enabled(...)`, so no gating. -#[test] -fn an_ungated_stack_gains_no_conditions() { - let template = render(&stack(false), "ungated queue"); - - assert!( - template.conditions.is_empty(), - "nothing is gated, so no condition belongs in the template: {:?}", - template.conditions - ); - assert!( - template - .resources - .values() - .all(|resource| resource.condition.is_none()), - "no resource may carry a condition when nothing is gated" - ); - assert_eq!( - registration_entry_ids(&template, &HashMap::new()), - vec!["execution-sa".to_string(), "jobs".to_string()], - ); -} diff --git a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs new file mode 100644 index 000000000..baa10e3fa --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs @@ -0,0 +1,494 @@ +//! `.enabled(input)` for `Queue` and `Vault` on CloudFormation. +//! +//! `enabled_tests.rs` covers the mechanics on `Kv`. `Vault` adds the shape `Kv` +//! never had: it owns no resource of its own — Parameter Store is a name prefix +//! — so the only things the emitter returns are the IAM policies granting +//! access to that prefix. Those must follow the gate too, or declining the vault +//! still hands out the permissions it was declined for. + +use super::helpers::{ + custom_resource_registration, gate_input, render_built_ins, render_built_ins_template, resolve, + Declined, +}; +use alien_cloudformation::{ + to_yaml, CfExpression, CfTemplate, CloudFormationTarget, RegistrationMode, +}; +use alien_core::{ + PermissionProfile, Queue, ResourceLifecycle, ServiceAccount, Stack, StackBuilder, + StackInputDefinition, StackSettings, Vault, +}; +use std::collections::HashMap; + +fn render_as(stack: &Stack, registration: RegistrationMode, description: &str) -> CfTemplate { + render_built_ins_template( + stack, + StackSettings::default(), + registration, + CloudFormationTarget::Aws, + "aws", + description, + ) + .0 +} + +fn render(stack: &Stack, description: &str) -> CfTemplate { + render_as(stack, custom_resource_registration(), description) +} + +const QUEUE_CONDITION: &str = "InputQueueEnabledIsTrue"; +const VAULT_CONDITION: &str = "InputVaultEnabledIsTrue"; + +fn gate_inputs() -> Vec { + vec![ + gate_input("queueEnabled", "queue", "Whether to create the queue."), + gate_input("vaultEnabled", "vault", "Whether to create the vault."), + ] +} + +/// Without a permission profile the vault emitter returns no resources at all, +/// so a missing `Condition` on its IAM policies would never show up. The service +/// account also gives the "off" answer an ungated neighbour to leave behind. +fn base() -> StackBuilder { + Stack::new("gated-stack".to_string()) + .inputs(gate_inputs()) + .permission( + "execution", + PermissionProfile::new() + .resource("secrets", ["vault/data-read"]) + .resource("jobs", ["queue/data-write"]), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) +} + +fn stack(gated: bool) -> Stack { + let queue = Queue::new("jobs".to_string()).build(); + let vault = Vault::new("secrets".to_string()).build(); + let builder = base(); + if gated { + builder + .add_enabled_when(queue, ResourceLifecycle::Frozen, "queueEnabled") + .add_enabled_when(vault, ResourceLifecycle::Frozen, "vaultEnabled") + .build() + } else { + builder + .add(queue, ResourceLifecycle::Frozen) + .add(vault, ResourceLifecycle::Frozen) + .build() + } +} + +fn registration_entry_ids(template: &CfTemplate, conditions: &HashMap<&str, bool>) -> Vec { + let payload = template + .resources + .get("DeploymentRegistration") + .expect("registration custom resource") + .properties + .get("Resources") + .expect("registration resource list") + .clone(); + let CfExpression::List(entries) = resolve(&payload, conditions, Declined::Removed) + .expect("payload survives condition resolution") + else { + panic!("registration payload should resolve to a list"); + }; + entries + .iter() + .map(|entry| { + let CfExpression::Object(fields) = entry else { + panic!("registration entry should be an object: {entry:?}"); + }; + match fields.get("id").expect("registration entry id") { + CfExpression::String(id) => id.clone(), + other => panic!("registration entry id should be a string: {other:?}"), + } + }) + .collect() +} + +/// The `DeploymentResources` output, resolved and parsed the way the registering +/// consumer reads it. +/// +/// Returns the raw text alongside the parsed value so a caller can assert on the +/// exact bytes CloudFormation would hand over, not just on what survived +/// `serde_json`. +fn resolved_outputs_payload( + template: &CfTemplate, + conditions: &HashMap<&str, bool>, +) -> (String, serde_json::Value) { + let value = &template + .outputs + .get("DeploymentResources") + .expect("outputs fallback should carry the resources payload") + .value; + let resolved = resolve(value, conditions, Declined::Removed) + .expect("the resources output survives condition resolution"); + let CfExpression::String(text) = resolved else { + panic!("the resources output should resolve to JSON text: {resolved:?}"); + }; + let parsed = serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("resources output should be valid JSON: {error}\n{text}")); + (text, parsed) +} + +/// Extract ids from the Outputs payload, verifying that every entry is a +/// well-formed object. +fn outputs_entry_ids(template: &CfTemplate, conditions: &HashMap<&str, bool>) -> Vec { + let (text, parsed) = resolved_outputs_payload(template, conditions); + let serde_json::Value::Array(entries) = parsed else { + panic!("resources output should be a JSON array: {text}"); + }; + entries + .iter() + .map(|entry| { + let id = entry + .get("id") + .unwrap_or_else(|| panic!("registration entry has no id: {entry}\n{text}")); + id.as_str() + .unwrap_or_else(|| panic!("registration entry id should be a string: {id}")) + .to_string() + }) + .collect() +} + +/// Logical ids of every resource carrying `condition`. +fn resources_conditioned_on(template: &CfTemplate, condition: &str) -> Vec { + let mut ids = template + .resources + .iter() + .filter(|(_, resource)| resource.condition.as_deref() == Some(condition)) + .map(|(logical_id, _)| logical_id.clone()) + .collect::>(); + ids.sort(); + ids +} + +#[test] +fn a_gated_queue_is_created_only_when_the_deployer_says_yes() { + let template = render(&stack(true), "gated queue and vault"); + + let queue = template + .resources + .get("Jobs") + .expect("gated queue should still be declared"); + assert_eq!(queue.resource_type, "AWS::SQS::Queue"); + assert_eq!(queue.condition.as_deref(), Some(QUEUE_CONDITION)); + assert_eq!( + resources_conditioned_on(&template, QUEUE_CONDITION), + vec!["Jobs".to_string()], + "the queue owns exactly one resource, and it is gated" + ); +} + +/// The vault is a name prefix, so its IAM policies are the only thing it +/// creates. A policy left ungated grants access to the declined vault's +/// namespace, which is the whole point of declining it. +/// +/// Scoped to what the vault's own emitter produced: the negative check below +/// only reads standalone `AWS::IAM::Policy` resources by `PolicyName`, so a +/// grant emitted by some *other* emitter — an inline policy on the service +/// account's role, say — would pass straight through it. That blind spot is +/// what `declining_the_vault_withdraws_every_grant_over_its_namespace` covers. +#[test] +fn a_gated_vault_gates_the_iam_policies_that_are_its_only_resources() { + let template = render(&stack(true), "gated queue and vault"); + + let gated = resources_conditioned_on(&template, VAULT_CONDITION); + assert!( + !gated.is_empty(), + "the fixture must actually render vault IAM, or this test proves nothing" + ); + for logical_id in &gated { + assert_eq!( + template.resources[logical_id].resource_type, "AWS::IAM::Policy", + "the vault creates nothing but IAM policies" + ); + } + assert!( + !template + .resources + .values() + .any(|resource| resource.resource_type == "AWS::IAM::Policy" + && resource.condition.is_none() + && resource + .properties + .get("PolicyName") + .map(|name| format!("{name:?}").contains("secrets")) + .unwrap_or(false)), + "no vault policy may survive the gate being off" + ); +} + +/// The invariant that matters most: registration runs the typed importer over +/// every entry it receives, so a declined resource has to be absent rather than +/// present-and-null. +#[test] +fn declined_resources_leave_no_registration_entry() { + let template = render(&stack(true), "gated queue and vault"); + + let all_on = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, true)]); + assert_eq!( + registration_entry_ids(&template, &all_on), + vec![ + "execution-sa".to_string(), + "jobs".to_string(), + "secrets".to_string() + ], + "everything registers when the deployer says yes" + ); + + let all_off = HashMap::from([(QUEUE_CONDITION, false), (VAULT_CONDITION, false)]); + assert_eq!( + registration_entry_ids(&template, &all_off), + vec!["execution-sa".to_string()], + "declined resources must be absent from the payload, not null" + ); + + let queue_only = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, false)]); + assert_eq!( + registration_entry_ids(&template, &queue_only), + vec!["execution-sa".to_string(), "jobs".to_string()], + "the two gates are independent" + ); +} + +/// The same invariant on the Outputs fallback, which is the path `alien render` +/// picks whenever no notification lambda is configured. +/// +/// It carries the payload as JSON text rather than as a list, and the intrinsic +/// that produces that text renders a declined entry as a literal `null` instead +/// of dropping it. Nothing about that is visible in the rendered template — it +/// only happens once CloudFormation resolves the conditions — so this asserts on +/// the resolved text. +#[test] +fn the_outputs_fallback_omits_declined_resources() { + let template = render_as( + &stack(true), + RegistrationMode::OutputsFallback, + "gated queue and vault, outputs fallback", + ); + + let all_on = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, true)]); + assert_eq!( + outputs_entry_ids(&template, &all_on), + vec![ + "execution-sa".to_string(), + "jobs".to_string(), + "secrets".to_string() + ], + "everything registers when the deployer says yes" + ); + + let all_off = HashMap::from([(QUEUE_CONDITION, false), (VAULT_CONDITION, false)]); + let (text, _) = resolved_outputs_payload(&template, &all_off); + assert!( + !text.contains("null"), + "a declined resource must leave nothing behind, but the payload still \ + carries a null: {text}" + ); + assert_eq!( + outputs_entry_ids(&template, &all_off), + vec!["execution-sa".to_string()], + "only the ungated resource registers" + ); + + let queue_only = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, false)]); + assert_eq!( + outputs_entry_ids(&template, &queue_only), + vec!["execution-sa".to_string(), "jobs".to_string()], + "the two gates are independent" + ); +} + +/// Declining every gated resource must still leave a payload the consumer can +/// read, rather than a malformed array or a stray delimiter. +#[test] +fn the_outputs_fallback_survives_every_resource_being_declined() { + let template = render_as( + &Stack::new("gated-stack".to_string()) + .inputs(gate_inputs()) + .add_enabled_when( + Queue::new("jobs".to_string()).build(), + ResourceLifecycle::Frozen, + "queueEnabled", + ) + .build(), + RegistrationMode::OutputsFallback, + "every resource gated, outputs fallback", + ); + + let all_off = HashMap::from([(QUEUE_CONDITION, false)]); + let (text, parsed) = resolved_outputs_payload(&template, &all_off); + assert_eq!(text, "[]", "an all-declined payload is an empty array"); + assert_eq!(parsed, serde_json::json!([])); +} + +/// Ungated stacks gain no conditions: opt-in means no `.enabled(...)`, so no gating. +#[test] +fn an_ungated_stack_gains_no_conditions() { + let template = render(&stack(false), "ungated queue and vault"); + + assert!( + template.conditions.is_empty(), + "nothing is gated, so no condition belongs in the template: {:?}", + template.conditions + ); + assert!( + template + .resources + .values() + .all(|resource| resource.condition.is_none()), + "no resource may carry a condition when nothing is gated" + ); + assert_eq!( + registration_entry_ids(&template, &HashMap::new()), + vec![ + "execution-sa".to_string(), + "jobs".to_string(), + "secrets".to_string() + ], + ); +} + +/// The gated template stays a template a security team can read end to end. +#[test] +fn the_gated_template_snapshot_stays_reviewable() { + let yaml = render_built_ins( + &stack(true), + StackSettings::default(), + RegistrationMode::OutputsFallback, + "gated queue and vault", + ); + insta::assert_snapshot!("enabled_gated_queue_and_vault", yaml); +} + +// ------------------------------------------- the grant, whoever emitted it + +/// A vault id a customer could actually gate. `secrets` — which `stack()` above +/// still uses — is the reserved deployment secrets vault, auto-wired to workers +/// after validation, and the preflight refuses to gate it. +const VAULT_ID: &str = "app-tokens"; + +/// The Parameter Store namespace the vault's grants are written against, exactly +/// as it appears inside the rendered `Fn::Sub`. +const VAULT_NAMESPACE: &str = "${AWS::StackName}-app-tokens-"; + +/// The vault grant scoped to the resource, which is the shape `.link()` authors. +/// A `"*"`-scoped grant is deliberately excluded: it lands on the role through +/// `stack_permission_sets` and is stack-wide by design, which is why +/// `ResourceEnabledValidCheck` rejects one for a gated resource type outright. +fn resource_scoped_stack() -> Stack { + Stack::new("gated-stack".to_string()) + .inputs(gate_inputs()) + .permission( + "execution", + PermissionProfile::new() + .resource(VAULT_ID, ["vault/data-read"]) + .resource("jobs", ["queue/data-write"]), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Queue::new("jobs".to_string()).build(), + ResourceLifecycle::Frozen, + "queueEnabled", + ) + .add_enabled_when( + Vault::new(VAULT_ID.to_string()).build(), + ResourceLifecycle::Frozen, + "vaultEnabled", + ) + .build() +} + +/// Logical ids of every IAM resource that still grants over `namespace` once +/// CloudFormation has resolved `conditions` — a resource whose `Condition` is +/// false is gone, and `Fn::If` inside a surviving one picks its branch. +/// +/// Deliberately blind to *which* emitter produced the grant, and to how it was +/// gated. Anything under `AWS::IAM::` is inspected whole, so an inline policy on +/// the service account's role counts exactly as much as the vault's own +/// standalone policy. +fn iam_grants_over( + template: &CfTemplate, + namespace: &str, + conditions: &HashMap<&str, bool>, +) -> Vec { + let mut ids = template + .resources + .iter() + .filter(|(_, resource)| match resource.condition.as_deref() { + Some(condition) => *conditions + .get(condition) + .unwrap_or_else(|| panic!("no answer supplied for condition '{condition}'")), + None => true, + }) + .filter(|(_, resource)| resource.resource_type.starts_with("AWS::IAM::")) + .filter(|(_, resource)| { + resource + .properties + .iter() + .filter_map(|(_, value)| resolve(value, conditions, Declined::Removed)) + .any(|value| format!("{value:?}").contains(namespace)) + }) + .map(|(logical_id, _)| logical_id.clone()) + .collect::>(); + ids.sort(); + ids +} + +/// The property, not the mechanism: a deployer who declines the vault must be +/// left with no IAM at all over its namespace. +/// +/// A "the vault's own policy carries the Condition" check misses this: two +/// emitters render the same grant and dedupe by body identity, so gating one +/// breaks the dedupe and leaves an ungated twin. This resolves the template with +/// the gate off and asks what access is still standing. +#[test] +fn declining_the_vault_withdraws_every_grant_over_its_namespace() { + let template = render(&resource_scoped_stack(), "resource-scoped gated vault"); + + let vault_on = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, true)]); + assert!( + !iam_grants_over(&template, VAULT_NAMESPACE, &vault_on).is_empty(), + "the fixture must actually render a grant over `{VAULT_NAMESPACE}` when the \ + deployer says yes, or the assertion below proves nothing" + ); + + let vault_off = HashMap::from([(QUEUE_CONDITION, true), (VAULT_CONDITION, false)]); + let leaked = iam_grants_over(&template, VAULT_NAMESPACE, &vault_off); + assert!( + leaked.is_empty(), + "these IAM resources still grant over `{VAULT_NAMESPACE}` after the vault was \ + declined, so saying no would not withdraw the access: {leaked:?}\n{}", + to_yaml(&template).expect("template should serialize") + ); +} + +/// The service account's role is the one thing that outlives every gate, so it is +/// where an ungated grant would have to hide. It carries only what the profile +/// puts at the `"*"` scope, and this stack puts nothing there. +#[test] +fn the_service_account_role_carries_no_resource_scoped_grant() { + let template = render(&resource_scoped_stack(), "resource-scoped gated vault"); + + let role = template + .resources + .get("ExecutionSaRole") + .expect("the service account role should be declared"); + assert_eq!(role.resource_type, "AWS::IAM::Role"); + assert!( + role.condition.is_none(), + "the role is ungated, which is why anything it holds outlives the vault's gate" + ); + assert!( + !role.properties.contains_key("Policies"), + "a resource-scoped grant belongs to the resource's own emitter, which gates it; \ + an inline policy here would survive the vault being declined: {:?}", + role.properties.get("Policies") + ); +} diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap new file mode 100644 index 000000000..0fcd78d2b --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap @@ -0,0 +1,277 @@ +--- +source: crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs +expression: yaml +--- +AWSTemplateFormatVersion: 2010-09-09 +Description: gated queue and vault +Transform: +- AWS::LanguageExtensions +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Registration + Parameters: + - Token + - ManagingRoleArn + - ManagingAccountId + - Label: + default: Application inputs + Parameters: + - InputQueueEnabled + - InputVaultEnabled + - Label: + default: Operations + Parameters: + - UpdatesMode + - TelemetryMode + - HeartbeatsMode + ParameterLabels: + HeartbeatsMode: + default: Heartbeats + InputQueueEnabled: + default: queue + InputVaultEnabled: + default: vault + ManagingAccountId: + default: Image account ID + ManagingRoleArn: + default: Management role ARN + TelemetryMode: + default: Telemetry + Token: + default: Install token + UpdatesMode: + default: Updates +Parameters: + Token: + Type: String + Description: Install token from the application setup page. + NoEcho: true + ManagingRoleArn: + Type: String + Description: ARN of the management identity allowed to assume setup-created roles. + Default: '' + ManagingAccountId: + Type: String + Description: AWS account ID for the management account that hosts application container images. + Default: '' + NetworkMode: + Type: String + Description: Choose create-new for a managed VPC, use-existing for your VPC, or use-default for the account default VPC. + Default: create-new + AllowedValues: + - create-new + - use-existing + - use-default + UpdatesMode: + Type: String + Description: How updates are applied after setup registration. + Default: auto + AllowedValues: + - auto + - approval-required + TelemetryMode: + Type: String + Description: Telemetry collection behavior. + Default: auto + AllowedValues: + - auto + HeartbeatsMode: + Type: String + Description: Heartbeat health-check behavior. + Default: "on" + AllowedValues: + - "off" + - "on" + InputQueueEnabled: + Type: String + Description: Whether to create the queue. + Default: 'true' + AllowedValues: + - 'true' + - 'false' + InputVaultEnabled: + Type: String + Description: Whether to create the vault. + Default: 'true' + AllowedValues: + - 'true' + - 'false' +Conditions: + InputQueueEnabledIsTrue: + Fn::Equals: + - Ref: InputQueueEnabled + - 'true' + InputVaultEnabledIsTrue: + Fn::Equals: + - Ref: InputVaultEnabled + - 'true' +Resources: + ExecutionSaRole: + Type: AWS::IAM::Role + Properties: + RoleName: + Fn::Sub: ${AWS::StackName}-execution-sa + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - codebuild.amazonaws.com + - ec2.amazonaws.com + - lambda.amazonaws.com + Action: sts:AssumeRole + Tags: + - Key: managed-by + Value: setup + - Key: deployment + Value: + Ref: AWS::StackName + - Key: resource + Value: execution-sa + - Key: resource-type + Value: service-account + Jobs: + Type: AWS::SQS::Queue + Properties: + SqsManagedSseEnabled: true + VisibilityTimeout: 30 + MessageRetentionPeriod: 345600 + Tags: + - Key: managed-by + Value: setup + - Key: deployment + Value: + Ref: AWS::StackName + - Key: resource + Value: jobs + - Key: resource-type + Value: queue + Condition: InputQueueEnabledIsTrue + SecretsExecutionSaRoleVaultPermission00: + Type: AWS::IAM::Policy + Properties: + PolicyName: + Fn::Sub: ${AWS::StackName}-secrets-vault-0-0 + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - ssm:GetParameter + Effect: Allow + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}-secrets-* + Sid: VaultDataRead + Roles: + - Ref: ExecutionSaRole + DependsOn: + - ExecutionSaRole + Condition: InputVaultEnabledIsTrue +Outputs: + DeploymentSourceKind: + Value: cloudformation + Description: Setup source kind. + DeploymentResourcePrefix: + Value: + Ref: AWS::StackName + Description: Stable physical resource prefix. + DeploymentPlatform: + Value: aws + Description: Target platform. + DeploymentRegion: + Value: + Ref: AWS::Region + Description: AWS region. + DeploymentSetupTarget: + Value: aws + Description: Setup target. + DeploymentSetupFingerprint: + Value: test + Description: Setup compatibility fingerprint. + DeploymentSetupImportFormatVersion: + Value: 1 + Description: Setup registration payload format version. + DeploymentSetupFingerprintVersion: + Value: 1 + Description: Setup fingerprint algorithm version. + DeploymentManagementConfig: + Value: + Fn::ToJsonString: + platform: aws + managingRoleArn: + Ref: ManagingRoleArn + Description: Deployment registration management configuration JSON. + DeploymentStackSettings: + Value: + Fn::ToJsonString: + deploymentModel: push + updates: + Ref: UpdatesMode + telemetry: + Ref: TelemetryMode + heartbeats: + Ref: HeartbeatsMode + network: + Ref: AWS::NoValue + Description: Deployment registration settings JSON. + DeploymentResources: + Value: + Fn::Join: + - '' + - - '[' + - Fn::Join: + - ',' + - - Fn::Sub: + - ${entry} + - entry: + Fn::ToJsonString: + id: execution-sa + type: service-account + importData: + roleName: + Ref: ExecutionSaRole + roleArn: + Fn::GetAtt: + - ExecutionSaRole + - Arn + stackPermissionsApplied: true + - Fn::If: + - InputQueueEnabledIsTrue + - Fn::Sub: + - ${entry} + - entry: + Fn::ToJsonString: + id: jobs + type: queue + importData: + queueName: + Fn::GetAtt: + - Jobs + - QueueName + queueUrl: + Ref: Jobs + queueArn: + Fn::GetAtt: + - Jobs + - Arn + - Ref: AWS::NoValue + - Fn::If: + - InputVaultEnabledIsTrue + - Fn::Sub: + - ${entry} + - entry: + Fn::ToJsonString: + id: secrets + type: vault + importData: + accountId: + Ref: AWS::AccountId + region: + Ref: AWS::Region + parameterPrefix: + Fn::Sub: ${AWS::StackName}-secrets + - Ref: AWS::NoValue + - ']' + Description: Deployment registration resources JSON. diff --git a/crates/alien-terraform/src/emitters/aws/vault.rs b/crates/alien-terraform/src/emitters/aws/vault.rs index 0129cdcae..a4b3fc87a 100644 --- a/crates/alien-terraform/src/emitters/aws/vault.rs +++ b/crates/alien-terraform/src/emitters/aws/vault.rs @@ -12,6 +12,7 @@ use crate::{ aws_terraform_permission_context, downcast, emit_iam_role_policy_for_target_with_label, iam_policy_name_sanitize, required_label, }, + emitters::enabled, expr, }; use alien_core::{ @@ -28,6 +29,7 @@ impl TfEmitter for AwsVaultEmitter { fn emit(&self, ctx: &EmitContext<'_>) -> Result { let vault = downcast::(ctx, Vault::RESOURCE_TYPE)?; let mut fragment = TfFragment::default(); + let enabled_when = ctx.resource.enabled_when.as_deref(); let context = aws_terraform_permission_context() .with_resource_name(format!("${{local.resource_prefix}}-{}", vault.id())); @@ -40,6 +42,7 @@ impl TfEmitter for AwsVaultEmitter { continue; } let vault_label_segment = sanitize_label_segment(vault.id()); + let appended_from = fragment.resource_blocks.len(); emit_iam_role_policy_for_target_with_label( &mut fragment, &owner_label, @@ -53,6 +56,9 @@ impl TfEmitter for AwsVaultEmitter { &context, BindingTarget::Resource, )?; + for block in &mut fragment.resource_blocks[appended_from..] { + enabled::gate(block, enabled_when)?; + } } } } @@ -67,6 +73,7 @@ impl TfEmitter for AwsVaultEmitter { continue; } let vault_label_segment = sanitize_label_segment(vault.id()); + let appended_from = fragment.resource_blocks.len(); emit_iam_role_policy_for_target_with_label( &mut fragment, management_label, @@ -80,6 +87,9 @@ impl TfEmitter for AwsVaultEmitter { &context, BindingTarget::Resource, )?; + for block in &mut fragment.resource_blocks[appended_from..] { + enabled::gate(block, enabled_when)?; + } } } } @@ -103,6 +113,21 @@ impl TfEmitter for AwsVaultEmitter { ])) } + /// The vault has no Terraform block of its own — it is a Parameter Store + /// name prefix — and its import data is built entirely from `local` and + /// `data` values. So nothing here needs an index: the IAM policies grant + /// against a static `${local.resource_prefix}--*` pattern, never against + /// a resource address. + /// + /// They still take the gate. `ssm:GetParameter` is a data-plane read, and + /// the pattern is a prefix wildcard: resource ids may contain hyphens, so a + /// declined vault `app` leaves a grant over `-app-*`, which matches + /// every parameter in a live sibling vault named `app-config`. Declining a + /// vault has to withdraw the permission, not just the registration entry. + fn supports_enabled_when(&self) -> bool { + true + } + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let vault = downcast::(ctx, Vault::RESOURCE_TYPE)?; Ok(Some(expr::object([ diff --git a/crates/alien-terraform/src/emitters/azure/vault.rs b/crates/alien-terraform/src/emitters/azure/vault.rs index def43ef37..d0e11d0f2 100644 --- a/crates/alien-terraform/src/emitters/azure/vault.rs +++ b/crates/alien-terraform/src/emitters/azure/vault.rs @@ -18,6 +18,7 @@ use crate::{ downcast, permission_context, required_label, service_account_principal_id, setup_execution_role_label, setup_management_role_label, tags, }, + emitters::enabled, expr, }; use alien_core::{ @@ -38,6 +39,7 @@ impl TfEmitter for AzureVaultEmitter { fn emit(&self, ctx: &EmitContext<'_>) -> Result { let vault = downcast::(ctx, Vault::RESOURCE_TYPE)?; let label = required_label(ctx)?; + let enabled_when = ctx.resource.enabled_when.as_deref(); let mut fragment = TfFragment::default(); // Scope the data source label to the vault resource. Multiple vaults can @@ -50,6 +52,9 @@ impl TfEmitter for AzureVaultEmitter { Vec::::new(), )); + // The random suffix is not gated: it is a local value with no cloud + // footprint, and leaving it uncounted keeps the vault's reference to it + // unindexed. let name_suffix_label = vault_name_suffix_label(label); fragment.resource_blocks.push(resource_block( "random_id", @@ -60,7 +65,7 @@ impl TfEmitter for AzureVaultEmitter { )], )); - fragment.resource_blocks.push(resource_block( + let mut key_vault = resource_block( "azurerm_key_vault", label, [ @@ -86,9 +91,11 @@ impl TfEmitter for AzureVaultEmitter { attr("public_network_access_enabled", Expression::Bool(true)), attr("tags", tags(ctx, "vault")), ], - )); + ); + enabled::gate(&mut key_vault, enabled_when)?; + fragment.resource_blocks.push(key_vault); - emit_vault_permissions(ctx, label, &mut fragment)?; + emit_vault_permissions(ctx, label, enabled_when, &mut fragment)?; Ok(fragment) } @@ -96,20 +103,25 @@ impl TfEmitter for AzureVaultEmitter { fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { let _ = downcast::(ctx, Vault::RESOURCE_TYPE)?; let label = required_label(ctx)?; + let enabled_when = ctx.resource.enabled_when.as_deref(); Ok(expr::object([ ("subscriptionId", expr::raw("var.azure_subscription_id")), ("resourceGroup", expr::raw("var.azure_resource_group_name")), ( "vaultName", - expr::traversal(["azurerm_key_vault", label, "name"]), + enabled::attribute(enabled_when, "azurerm_key_vault", label, "name"), ), ( "vaultUri", - expr::traversal(["azurerm_key_vault", label, "vault_uri"]), + enabled::attribute(enabled_when, "azurerm_key_vault", label, "vault_uri"), ), ])) } + fn supports_enabled_when(&self) -> bool { + true + } + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let _ = downcast::(ctx, Vault::RESOURCE_TYPE)?; let label = required_label(ctx)?; @@ -141,8 +153,14 @@ fn vault_name_suffix_label(vault_label: &str) -> String { fn emit_vault_permissions( ctx: &EmitContext<'_>, vault_label: &str, + enabled_when: Option<&str>, fragment: &mut TfFragment, ) -> Result<()> { + let vault = VaultTarget { + label: vault_label, + enabled_when, + }; + for (profile_name, permission_set_refs) in vault_permission_owners(ctx) { let Some(principal_id_expr) = service_account_principal_id(ctx, &profile_name) else { continue; @@ -197,13 +215,13 @@ fn emit_vault_permissions( }; emit_role_assignment( fragment, - vault_label, + vault, &profile_name, binding_index, &binding.role_name, role_definition_id, principal_id_expr.clone(), - ); + )?; } } } @@ -265,50 +283,74 @@ fn emit_vault_permissions( }; emit_role_assignment( fragment, - vault_label, + vault, "management", binding_index, &binding.role_name, role_definition_id, principal_id_expr.clone(), - ); + )?; } } Ok(()) } +/// The vault a role assignment attaches to. The label and the gate travel +/// together because every reference to the vault has to agree on whether the +/// `[0]` is there. +#[derive(Clone, Copy)] +struct VaultTarget<'a> { + label: &'a str, + enabled_when: Option<&'a str>, +} + +impl VaultTarget<'_> { + /// Address of the vault block, indexed when it is counted. + fn address(&self) -> String { + match self.enabled_when { + Some(_) => format!("azurerm_key_vault.{}[0]", self.label), + None => format!("azurerm_key_vault.{}", self.label), + } + } +} + fn emit_role_assignment( fragment: &mut TfFragment, - vault_label: &str, + vault: VaultTarget<'_>, principal_label: &str, binding_index: usize, role_name: &str, role_definition_id: Expression, principal_id_expr: Expression, -) { +) -> Result<()> { let role_label = sanitize_role_label(role_name); - fragment.resource_blocks.push(resource_block( + let vault_label = vault.label; + // The assignment names the vault twice — once as its scope, once inside the + // uuidv5 seed — so both read the same address. + let vault_address = vault.address(); + let mut assignment = resource_block( "azurerm_role_assignment", &format!("{vault_label}_{role_label}_{principal_label}_assignment_{binding_index}"), [ attr( "name", expr::raw(&format!( - "uuidv5(\"oid\", \"deployment:azure:vault-role-assign:${{azurerm_key_vault.{vault_label}.id}}:{role_label}:{principal_label}:{binding_index}\")" + "uuidv5(\"oid\", \"deployment:azure:vault-role-assign:${{{vault_address}.id}}:{role_label}:{principal_label}:{binding_index}\")" )), ), attr( "scope", - expr::traversal(["azurerm_key_vault", vault_label, "id"]), - ), - attr( - "role_definition_id", - role_definition_id, + enabled::attribute(vault.enabled_when, "azurerm_key_vault", vault_label, "id"), ), + attr("role_definition_id", role_definition_id), attr("principal_id", principal_id_expr), ], - )); + ); + // The grant targets this vault, so it only exists while the vault does. + enabled::gate(&mut assignment, vault.enabled_when)?; + fragment.resource_blocks.push(assignment); + Ok(()) } fn sanitize_role_label(input: &str) -> String { diff --git a/crates/alien-terraform/src/emitters/gcp/vault.rs b/crates/alien-terraform/src/emitters/gcp/vault.rs index cdd23d9c5..ce61b74a0 100644 --- a/crates/alien-terraform/src/emitters/gcp/vault.rs +++ b/crates/alien-terraform/src/emitters/gcp/vault.rs @@ -9,8 +9,8 @@ use crate::{ block::{attr, data_block}, emitter::{TfEmitter, TfFragment}, emitters::gcp::helpers::{ - downcast, emit_custom_role_and_bindings_for_target, permission_context, required_label, - service_account_member_for_label, + downcast, emit_custom_role_and_bindings_for_target, gate_bindings, permission_context, + required_label, service_account_member_for_label, }, expr, }; @@ -29,6 +29,7 @@ impl TfEmitter for GcpVaultEmitter { let vault = downcast::(ctx, Vault::RESOURCE_TYPE)?; let vault_label = required_label(ctx)?; let mut fragment = TfFragment::default(); + let enabled_when = ctx.resource.enabled_when.as_deref(); let vault_permission_owners = vault_permission_owners(ctx); if !vault_permission_owners.is_empty() && remote_stack_management_label(ctx).is_none() { @@ -54,6 +55,7 @@ impl TfEmitter for GcpVaultEmitter { } let binding_owner_label = binding_owner_label(&role_owner_label, &permission_set.id); + let appended_from = fragment.resource_blocks.len(); emit_custom_role_and_bindings_for_target( &mut fragment, &binding_owner_label, @@ -62,6 +64,7 @@ impl TfEmitter for GcpVaultEmitter { &context, BindingTarget::Resource, )?; + gate_bindings(&mut fragment, appended_from, enabled_when)?; } } } @@ -84,6 +87,7 @@ impl TfEmitter for GcpVaultEmitter { } let binding_owner_label = binding_owner_label(&role_owner_label, &permission_set.id); + let appended_from = fragment.resource_blocks.len(); emit_custom_role_and_bindings_for_target( &mut fragment, &binding_owner_label, @@ -92,6 +96,7 @@ impl TfEmitter for GcpVaultEmitter { &context, BindingTarget::Resource, )?; + gate_bindings(&mut fragment, appended_from, enabled_when)?; } } @@ -110,6 +115,20 @@ impl TfEmitter for GcpVaultEmitter { ])) } + /// The vault has no Terraform block of its own — it is a Secret Manager + /// name prefix — and its import data is built entirely from `local` and + /// `var` values. So nothing here needs an index: the role bindings are + /// project-scoped with a name-prefix condition, never a resource address. + /// + /// They still take the gate. `roles/secretmanager.secretAccessor` reads + /// secret payloads, and the condition matches on a prefix: resource ids may + /// contain hyphens, so a declined vault `app` leaves a grant over secrets + /// starting `-app-`, which covers a live sibling vault named + /// `app-config`. Only the bindings are gated — see `gate_bindings`. + fn supports_enabled_when(&self) -> bool { + true + } + fn emit_binding_ref(&self, ctx: &EmitContext<'_>) -> Result> { let vault = downcast::(ctx, Vault::RESOURCE_TYPE)?; Ok(Some(expr::object([ diff --git a/crates/alien-terraform/tests/generator.rs b/crates/alien-terraform/tests/generator.rs index 48e345b45..b640e6086 100644 --- a/crates/alien-terraform/tests/generator.rs +++ b/crates/alien-terraform/tests/generator.rs @@ -17,9 +17,9 @@ mod generator { pub mod azure_data_layer_tests; pub mod azure_full_stack_tests; pub mod azure_identity_tests; - pub mod enabled_queue_tests; pub mod enabled_storage_tests; pub mod enabled_tests; + pub mod enabled_vault_queue_tests; pub mod gcp_compute_tests; pub mod gcp_data_layer_tests; pub mod gcp_full_stack_tests; diff --git a/crates/alien-terraform/tests/generator/enabled_queue_tests.rs b/crates/alien-terraform/tests/generator/enabled_queue_tests.rs deleted file mode 100644 index de2d78390..000000000 --- a/crates/alien-terraform/tests/generator/enabled_queue_tests.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! `.enabled(input)` for `Queue`. -//! -//! `enabled_tests.rs` covers the mechanics on `Kv`. A queue adds the shape `Kv` -//! never had: it owns more than one block and grants IAM against them. The -//! recurring failure mode is a self-reference that keeps rendering after its -//! resource became a list — valid HCL, rejected by `terraform validate` — so -//! every gated scenario here is linted. - -use super::helpers::{ - assert_terraform_valid, assert_ungated_registration_list_is_a_plain_array, - declared_block_types, gate_input, gated_block_types, normalized, render, -}; -use alien_core::{ - AzureResourceGroup, AzureServiceBusNamespace, PermissionProfile, Queue, ResourceLifecycle, - ServiceAccount, Stack, StackBuilder, StackInputDefinition, StackSettings, -}; -use alien_terraform::TerraformTarget; - -const QUEUE_GATE: &str = "count = var.input_queue_enabled ? 1 : 0"; - -fn gate_inputs() -> Vec { - vec![gate_input( - "queueEnabled", - "queue", - "Whether to create the queue.", - )] -} - -/// A gated resource has to keep rendering valid Terraform for everything that -/// points at it, and IAM is where the pointers are. A stack without a permission -/// profile emits no IAM blocks at all, so a missed index would never reach -/// `terraform validate` — which is exactly how this bug ships. -fn permissioned(builder: StackBuilder, resource_id: &str, permissions: &[&str]) -> StackBuilder { - builder - .permission( - "execution", - PermissionProfile::new().resource(resource_id, permissions.to_vec()), - ) - .add( - ServiceAccount::new("execution-sa".to_string()).build(), - ResourceLifecycle::Frozen, - ) -} - -fn queue_stack(gated: bool) -> Stack { - let builder = permissioned( - Stack::new("gated-stack".to_string()).inputs(gate_inputs()), - "jobs", - &["queue/data-write"], - ); - add_queue(builder, gated).build() -} - -/// `queue/management` grants raw permissions, so the emitter defines a project -/// custom role for it — the shared block a resource's gate must never reach. -/// `queue/data-write` (used above) is only predefined roles, so it emits no -/// custom role at all: the gate-exclusion test needs this fixture to have -/// anything to check. -fn custom_role_queue_stack(gated: bool) -> Stack { - let builder = permissioned( - Stack::new("gated-stack".to_string()).inputs(gate_inputs()), - "jobs", - &["queue/management"], - ); - add_queue(builder, gated).build() -} - -/// Azure realises the queue inside a shared Service Bus namespace. The rebuild -/// preflight injects that namespace and its resource group at runtime; neither -/// is gated, only what the tests add on top. -fn azure_queue_stack(gated: bool) -> Stack { - let builder = Stack::new("gated-stack".to_string()) - .inputs(gate_inputs()) - .add( - AzureResourceGroup::new("default-resource-group".to_string()).build(), - ResourceLifecycle::Frozen, - ) - .add( - AzureServiceBusNamespace::new("default-service-bus-namespace".to_string()).build(), - ResourceLifecycle::Frozen, - ); - add_queue(builder, gated).build() -} - -fn add_queue(builder: StackBuilder, gated: bool) -> StackBuilder { - let queue = Queue::new("jobs".to_string()).build(); - if gated { - builder.add_enabled_when(queue, ResourceLifecycle::Frozen, "queueEnabled") - } else { - builder.add(queue, ResourceLifecycle::Frozen) - } -} - -/// The registration list only changes shape once something in the stack is -/// gated, and a declined resource contributes no entry rather than a null one. -fn assert_gated_registration_list(main: &str, gate_variable: &str) { - assert!( - main.contains("deployment_resources = concat("), - "a gated stack splices its registration list together:\n{main}" - ); - assert!( - main.contains(&format!("{gate_variable} ? [")) && main.contains("] : []"), - "the gated entry must collapse to an empty list, not to null:\n{main}" - ); - assert!( - !main.contains(": null"), - "no null may reach the registration payload:\n{main}" - ); -} - -// ---------------------------------------------------------------- AWS queue - -#[test] -fn a_gated_aws_queue_is_counted_and_indexed() { - let module = render( - &queue_stack(true), - TerraformTarget::Aws, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - main.contains("resource \"aws_sqs_queue\""), - "the queue block is still declared:\n{main}" - ); - assert!( - main.contains(QUEUE_GATE), - "the queue must be created only when the deployer says yes:\n{main}" - ); - for attribute in ["name", "url", "arn"] { - assert!( - main.contains(&format!("aws_sqs_queue.jobs[0].{attribute}")), - "every self-reference must be indexed, not just the first ({attribute}):\n{main}" - ); - } - assert_gated_registration_list(main, "var.input_queue_enabled"); - assert_terraform_valid(&module, "gated aws queue stack"); -} - -#[test] -fn an_ungated_aws_queue_is_untouched() { - let module = render( - &queue_stack(false), - TerraformTarget::Aws, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - !main.contains("aws_sqs_queue.jobs[0]"), - "no indexing on an ungated queue:\n{main}" - ); - assert!( - main.contains("aws_sqs_queue.jobs.arn"), - "references stay unindexed:\n{main}" - ); - assert!( - !main.contains(QUEUE_GATE), - "nothing is gated, so no count belongs in the module:\n{main}" - ); - assert_ungated_registration_list_is_a_plain_array(main); -} - -// ---------------------------------------------------------------- GCP queue - -/// GCP is the only cloud where one Alien queue owns two blocks, and the -/// subscription points at the topic. Gating one without the other renders fine -/// and fails `terraform validate`. -#[test] -fn a_gated_gcp_queue_counts_both_of_its_blocks() { - let module = render( - &queue_stack(true), - TerraformTarget::Gcp, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert_eq!( - main.matches(QUEUE_GATE).count(), - 5, - "topic, subscription and the three IAM members all follow the gate:\n{main}" - ); - assert!( - main.contains("topic = google_pubsub_topic.jobs[0].id"), - "the subscription points at the counted topic:\n{main}" - ); - assert_terraform_valid(&module, "gated gcp queue stack"); -} - -/// The IAM members grant against the topic and the subscription by name. They -/// are the references most easily missed, because a stack without a permission -/// profile does not render them at all. -#[test] -fn a_gated_gcp_queue_leaves_no_unindexed_self_reference() { - let module = render( - &queue_stack(true), - TerraformTarget::Gcp, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - main.contains("google_pubsub_topic_iam_member"), - "the fixture must actually render queue IAM, or this test proves nothing:\n{main}" - ); - assert!( - main.contains("topic = google_pubsub_topic.jobs[0].name") - && main.contains("subscription = google_pubsub_subscription.jobs[0].name"), - "IAM members address the counted instances:\n{main}" - ); - for block in ["google_pubsub_topic", "google_pubsub_subscription"] { - assert!( - !main.contains(&format!("{block}.jobs.")), - "no attribute may be read off {block} without an index:\n{main}" - ); - } - assert_gated_registration_list(main, "var.input_queue_enabled"); -} - -/// The project-wide custom roles are shared across resources and carry their own -/// `var.gcp_manage_custom_roles` count. Tying them to one resource's gate would -/// delete roles other resources still need. -#[test] -fn a_gated_gcp_queue_leaves_shared_custom_roles_alone() { - let module = render( - &custom_role_queue_stack(true), - TerraformTarget::Gcp, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - declared_block_types(main) - .iter() - .any(|found| found == "google_project_iam_custom_role"), - "the fixture must actually produce a custom role, or this proves nothing:\n{main}" - ); - assert!( - !gated_block_types(main, QUEUE_GATE) - .iter() - .any(|found| found == "google_project_iam_custom_role"), - "a shared custom role must not follow one resource's gate:\n{main}" - ); - assert!( - main.contains("var.gcp_manage_custom_roles ? 1 : 0"), - "the custom role keeps its own count:\n{main}" - ); -} - -#[test] -fn an_ungated_gcp_queue_is_untouched() { - let module = render( - &queue_stack(false), - TerraformTarget::Gcp, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - !main.contains("google_pubsub_topic.jobs[0]") - && !main.contains("google_pubsub_subscription.jobs[0]"), - "no indexing on an ungated queue:\n{main}" - ); - assert!( - main.contains("topic = google_pubsub_topic.jobs.id"), - "references stay unindexed:\n{main}" - ); - assert!( - !main.contains(QUEUE_GATE), - "nothing is gated, so no gate count belongs in the module:\n{main}" - ); - assert_ungated_registration_list_is_a_plain_array(main); -} - -// -------------------------------------------------------------- Azure queue - -/// The Azure payload mixes the gated queue with the ungated namespace hosting -/// it. Indexing the latter would produce Terraform that does not validate. -#[test] -fn a_gated_azure_queue_indexes_only_its_own_references() { - let module = render( - &azure_queue_stack(true), - TerraformTarget::Azure, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - main.contains(QUEUE_GATE), - "the queue must be created only when the deployer says yes:\n{main}" - ); - assert!( - main.contains("azurerm_servicebus_queue.jobs[0].name"), - "references to the counted queue must be indexed:\n{main}" - ); - assert!( - !main.contains("azurerm_servicebus_namespace.default_service_bus_namespace[0]"), - "the parent namespace is not counted, so it must stay unindexed:\n{main}" - ); - assert!( - main.contains("azurerm_servicebus_namespace.default_service_bus_namespace.name"), - "the parent namespace name is still read directly:\n{main}" - ); - assert_gated_registration_list(main, "var.input_queue_enabled"); - assert_terraform_valid(&module, "gated azure queue stack"); -} - -#[test] -fn an_ungated_azure_queue_is_untouched() { - let module = render( - &azure_queue_stack(false), - TerraformTarget::Azure, - StackSettings::default(), - ); - let main = &normalized(&module); - - assert!( - !main.contains("azurerm_servicebus_queue.jobs[0]"), - "no indexing on an ungated queue:\n{main}" - ); - assert!( - main.contains("azurerm_servicebus_queue.jobs.name"), - "references stay unindexed:\n{main}" - ); - assert!( - !main.contains(QUEUE_GATE), - "nothing is gated, so no count belongs in the module:\n{main}" - ); - assert_ungated_registration_list_is_a_plain_array(main); -} diff --git a/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs new file mode 100644 index 000000000..c9fed3689 --- /dev/null +++ b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs @@ -0,0 +1,628 @@ +//! `.enabled(input)` for `Queue` and `Vault`. +//! +//! `enabled_tests.rs` covers the mechanics on `Kv`. These two resource types +//! add the shapes `Kv` never had: a queue that owns more than one block and +//! grants IAM against them, and a vault that on two of three clouds owns no +//! block at all. The recurring failure mode is a self-reference that keeps +//! rendering after its resource became a list — valid HCL, rejected by +//! `terraform validate` — so every gated scenario here is linted. + +use super::helpers::{ + assert_terraform_valid, assert_ungated_registration_list_is_a_plain_array, gate_input, + normalized, render, +}; +use alien_core::{ + AzureResourceGroup, AzureServiceBusNamespace, PermissionProfile, Queue, ResourceLifecycle, + ServiceAccount, Stack, StackBuilder, StackInputDefinition, StackSettings, Vault, +}; +use alien_terraform::TerraformTarget; + +const QUEUE_GATE: &str = "count = var.input_queue_enabled ? 1 : 0"; +const VAULT_GATE: &str = "count = var.input_vault_enabled ? 1 : 0"; + +fn gate_inputs() -> Vec { + vec![ + gate_input("queueEnabled", "queue", "Whether to create the queue."), + gate_input("vaultEnabled", "vault", "Whether to create the vault."), + ] +} + +/// A gated resource has to keep rendering valid Terraform for everything that +/// points at it, and IAM is where the pointers are. A stack without a permission +/// profile emits no IAM blocks at all, so a missed index would never reach +/// `terraform validate` — which is exactly how this bug ships. +fn permissioned(builder: StackBuilder, resource_id: &str, permissions: &[&str]) -> StackBuilder { + builder + .permission( + "execution", + PermissionProfile::new().resource(resource_id, permissions.to_vec()), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) +} + +fn queue_stack(gated: bool) -> Stack { + let builder = permissioned( + Stack::new("gated-stack".to_string()).inputs(gate_inputs()), + "jobs", + &["queue/data-write"], + ); + add_queue(builder, gated).build() +} + +/// `queue/management` grants raw permissions, so the emitter defines a project +/// custom role for it — the shared block a resource's gate must never reach. +/// `queue/data-write` is predefined roles only and renders no custom role, so +/// the gate-exclusion test below needs this fixture to have anything to check. +fn custom_role_queue_stack(gated: bool) -> Stack { + let builder = permissioned( + Stack::new("gated-stack".to_string()).inputs(gate_inputs()), + "jobs", + &["queue/management"], + ); + add_queue(builder, gated).build() +} + +/// Azure realises the queue inside a shared Service Bus namespace, and the +/// vault beside a resource group. The rebuild preflight injects both at +/// runtime; neither is gated, only what the tests add on top. +fn azure_queue_stack(gated: bool) -> Stack { + let builder = Stack::new("gated-stack".to_string()) + .inputs(gate_inputs()) + .add( + AzureResourceGroup::new("default-resource-group".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + AzureServiceBusNamespace::new("default-service-bus-namespace".to_string()).build(), + ResourceLifecycle::Frozen, + ); + add_queue(builder, gated).build() +} + +/// `secrets` is the reserved deployment secrets vault — it is wired to Workers +/// and compute clusters automatically after validation, so the preflight refuses +/// to gate it. A vault whose gate these tests assert on needs an id a customer +/// could actually use. +const VAULT_ID: &str = "app-tokens"; + +fn vault_stack(gated: bool) -> Stack { + let builder = permissioned( + Stack::new("gated-stack".to_string()).inputs(gate_inputs()), + VAULT_ID, + &["vault/data-read"], + ); + add_vault(builder, gated).build() +} + +/// `vault/data-write` grants a raw permission on GCP, which renders as a +/// project-wide custom role beside the binding. `vault/data-read` on its own +/// resolves to predefined roles, so it never exercises that path. +fn vault_stack_with_custom_role(gated: bool) -> Stack { + let builder = permissioned( + Stack::new("gated-stack".to_string()).inputs(gate_inputs()), + VAULT_ID, + &["vault/data-read", "vault/data-write"], + ); + add_vault(builder, gated).build() +} + +fn azure_vault_stack(gated: bool) -> Stack { + let builder = permissioned( + Stack::new("gated-stack".to_string()) + .inputs(gate_inputs()) + .add( + AzureResourceGroup::new("default-resource-group".to_string()).build(), + ResourceLifecycle::Frozen, + ), + VAULT_ID, + &["vault/data-read"], + ); + add_vault(builder, gated).build() +} + +fn add_queue(builder: StackBuilder, gated: bool) -> StackBuilder { + let queue = Queue::new("jobs".to_string()).build(); + if gated { + builder.add_enabled_when(queue, ResourceLifecycle::Frozen, "queueEnabled") + } else { + builder.add(queue, ResourceLifecycle::Frozen) + } +} + +fn add_vault(builder: StackBuilder, gated: bool) -> StackBuilder { + let vault = Vault::new(VAULT_ID.to_string()).build(); + if gated { + builder.add_enabled_when(vault, ResourceLifecycle::Frozen, "vaultEnabled") + } else { + builder.add(vault, ResourceLifecycle::Frozen) + } +} + +/// The registration list only changes shape once something in the stack is +/// gated, and a declined resource contributes no entry rather than a null one. +fn assert_gated_registration_list(main: &str, gate_variable: &str) { + assert!( + main.contains("deployment_resources = concat("), + "a gated stack splices its registration list together:\n{main}" + ); + assert!( + main.contains(&format!("{gate_variable} ? [")) && main.contains("] : []"), + "the gated entry must collapse to an empty list, not to null:\n{main}" + ); + assert!( + !main.contains(": null"), + "no null may reach the registration payload:\n{main}" + ); +} + +// ---------------------------------------------------------------- AWS queue + +#[test] +fn a_gated_aws_queue_is_counted_and_indexed() { + let module = render( + &queue_stack(true), + TerraformTarget::Aws, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("resource \"aws_sqs_queue\""), + "the queue block is still declared:\n{main}" + ); + assert!( + main.contains(QUEUE_GATE), + "the queue must be created only when the deployer says yes:\n{main}" + ); + for attribute in ["name", "url", "arn"] { + assert!( + main.contains(&format!("aws_sqs_queue.jobs[0].{attribute}")), + "every self-reference must be indexed, not just the first ({attribute}):\n{main}" + ); + } + assert_gated_registration_list(main, "var.input_queue_enabled"); + assert_terraform_valid(&module, "gated aws queue stack"); +} + +#[test] +fn an_ungated_aws_queue_is_untouched() { + let module = render( + &queue_stack(false), + TerraformTarget::Aws, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + !main.contains("aws_sqs_queue.jobs[0]"), + "no indexing on an ungated queue:\n{main}" + ); + assert!( + main.contains("aws_sqs_queue.jobs.arn"), + "references stay unindexed:\n{main}" + ); + assert!( + !main.contains(QUEUE_GATE), + "nothing is gated, so no count belongs in the module:\n{main}" + ); + assert_ungated_registration_list_is_a_plain_array(main); +} + +// ---------------------------------------------------------------- GCP queue + +/// GCP is the only cloud where one Alien queue owns two blocks, and the +/// subscription points at the topic. Gating one without the other renders fine +/// and fails `terraform validate`. +#[test] +fn a_gated_gcp_queue_counts_both_of_its_blocks() { + let module = render( + &queue_stack(true), + TerraformTarget::Gcp, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert_eq!( + main.matches(QUEUE_GATE).count(), + 5, + "topic, subscription and the three IAM members all follow the gate:\n{main}" + ); + assert!( + main.contains("topic = google_pubsub_topic.jobs[0].id"), + "the subscription points at the counted topic:\n{main}" + ); + assert_terraform_valid(&module, "gated gcp queue stack"); +} + +/// The IAM members grant against the topic and the subscription by name. They +/// are the references most easily missed, because a stack without a permission +/// profile does not render them at all. +#[test] +fn a_gated_gcp_queue_leaves_no_unindexed_self_reference() { + let module = render( + &queue_stack(true), + TerraformTarget::Gcp, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("google_pubsub_topic_iam_member"), + "the fixture must actually render queue IAM, or this test proves nothing:\n{main}" + ); + assert!( + main.contains("topic = google_pubsub_topic.jobs[0].name") + && main.contains("subscription = google_pubsub_subscription.jobs[0].name"), + "IAM members address the counted instances:\n{main}" + ); + for block in ["google_pubsub_topic", "google_pubsub_subscription"] { + assert!( + !main.contains(&format!("{block}.jobs.")), + "no attribute may be read off {block} without an index:\n{main}" + ); + } + assert_gated_registration_list(main, "var.input_queue_enabled"); +} + +/// The project-wide custom roles are shared across resources and carry their own +/// `var.gcp_manage_custom_roles` count. Tying them to one resource's gate would +/// delete roles other resources still need. +#[test] +fn a_gated_gcp_queue_leaves_shared_custom_roles_alone() { + let module = render( + &custom_role_queue_stack(true), + TerraformTarget::Gcp, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("resource \"google_project_iam_custom_role\""), + "the fixture must actually render a custom role, or this proves nothing:\n{main}" + ); + let gated_types = gated_block_types(main, QUEUE_GATE); + assert!( + !gated_types + .iter() + .any(|found| found == "google_project_iam_custom_role"), + "a shared custom role must not follow one resource's gate, found {gated_types:?}:\n{main}" + ); + assert!( + gated_types + .iter() + .any(|found| found == "google_pubsub_topic_iam_member"), + "the bindings beside it still follow the gate, found {gated_types:?}:\n{main}" + ); + assert!( + main.contains("count = var.gcp_manage_custom_roles ? 1 : 0"), + "the custom role keeps the count it already had:\n{main}" + ); +} + +#[test] +fn an_ungated_gcp_queue_is_untouched() { + let module = render( + &queue_stack(false), + TerraformTarget::Gcp, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + !main.contains("google_pubsub_topic.jobs[0]") + && !main.contains("google_pubsub_subscription.jobs[0]"), + "no indexing on an ungated queue:\n{main}" + ); + assert!( + main.contains("topic = google_pubsub_topic.jobs.id"), + "references stay unindexed:\n{main}" + ); + assert!( + !main.contains(QUEUE_GATE), + "nothing is gated, so no gate count belongs in the module:\n{main}" + ); + assert_ungated_registration_list_is_a_plain_array(main); +} + +// -------------------------------------------------------------- Azure queue + +/// The Azure payload mixes the gated queue with the ungated namespace hosting +/// it. Indexing the latter would produce Terraform that does not validate. +#[test] +fn a_gated_azure_queue_indexes_only_its_own_references() { + let module = render( + &azure_queue_stack(true), + TerraformTarget::Azure, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains(QUEUE_GATE), + "the queue must be created only when the deployer says yes:\n{main}" + ); + assert!( + main.contains("azurerm_servicebus_queue.jobs[0].name"), + "references to the counted queue must be indexed:\n{main}" + ); + assert!( + !main.contains("azurerm_servicebus_namespace.default_service_bus_namespace[0]"), + "the parent namespace is not counted, so it must stay unindexed:\n{main}" + ); + assert!( + main.contains("azurerm_servicebus_namespace.default_service_bus_namespace.name"), + "the parent namespace name is still read directly:\n{main}" + ); + assert_gated_registration_list(main, "var.input_queue_enabled"); + assert_terraform_valid(&module, "gated azure queue stack"); +} + +#[test] +fn an_ungated_azure_queue_is_untouched() { + let module = render( + &azure_queue_stack(false), + TerraformTarget::Azure, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + !main.contains("azurerm_servicebus_queue.jobs[0]"), + "no indexing on an ungated queue:\n{main}" + ); + assert!( + main.contains("azurerm_servicebus_queue.jobs.name"), + "references stay unindexed:\n{main}" + ); + assert!( + !main.contains(QUEUE_GATE), + "nothing is gated, so no count belongs in the module:\n{main}" + ); + assert_ungated_registration_list_is_a_plain_array(main); +} + +// -------------------------------------------------------- AWS / GCP vault + +/// Parse the module into `(type, label, body)` per `resource` block; each body +/// runs to the next `resource "` header. +fn resource_blocks(main: &str) -> Vec<(String, String, String)> { + let mut blocks = Vec::new(); + for (index, _) in main.match_indices("resource \"") { + let rest = &main[index + "resource \"".len()..]; + let Some((block_type, tail)) = rest.split_once('"') else { + continue; + }; + let Some((label, body)) = tail.trim_start().trim_start_matches('"').split_once('"') else { + continue; + }; + let body_end = body.find("resource \"").unwrap_or(body.len()); + blocks.push(( + block_type.to_string(), + label.to_string(), + body[..body_end].to_string(), + )); + } + blocks +} + +/// Which block types carry `gate`, so an assertion can say "exactly these" +/// rather than "at least the one I happened to look at". +fn gated_block_types(main: &str, gate: &str) -> Vec { + resource_blocks(main) + .into_iter() + .filter(|(_, _, body)| body.contains(gate)) + .map(|(block_type, _, _)| block_type) + .collect() +} + +/// The property that actually matters: no block granting against the declined +/// vault's namespace may survive without the gate. +/// +/// A "some block carries the gate" check is not enough: two emitters render the +/// grant and the generator dedupes them only while their bodies match, so gating +/// one leaves an ungated twin holding the access. +fn assert_no_ungated_grant_over(main: &str, namespace: &str, gate: &str) { + let leaked: Vec = resource_blocks(main) + .into_iter() + .filter(|(_, _, body)| body.contains(namespace) && !body.contains(gate)) + .map(|(block_type, label, _)| format!("{block_type}.{label}")) + .collect(); + assert!( + leaked.is_empty(), + "these blocks still grant against `{namespace}` with no gate, so declining \ + the vault would not withdraw the access: {leaked:?}\n{main}" + ); +} + +/// On AWS and GCP the vault owns no block of its own — it is a name prefix, and +/// its import data is built from `local` / `var` / `data` values. So there is +/// nothing to index, and the prefix it reports must not change. +/// +/// Its access policy is a different matter. The grant is a data-plane read +/// (`ssm:GetParameter`, `roles/secretmanager.secretAccessor`) over a prefix +/// wildcard, and resource ids may contain hyphens — so a declined vault `app` +/// left holding `-app-*` can read a live sibling vault `app-config`. +/// Declining a vault has to withdraw the permission, not just the registration +/// entry, which is also what the CloudFormation generator does. +#[test] +fn a_gated_prefix_only_vault_gates_the_access_policy_it_owns() { + for (target, name, grant_block) in [ + (TerraformTarget::Aws, "aws", "aws_iam_role_policy"), + (TerraformTarget::Gcp, "gcp", "google_project_iam_member"), + ] { + let gated = render(&vault_stack(true), target, StackSettings::default()); + let gated_main = &normalized(&gated); + + assert!( + gated_main.contains(&format!("resource \"{grant_block}\"")), + "the fixture must actually render {name} vault IAM, or this proves nothing:\n{gated_main}" + ); + assert!( + gated_main.contains("parameterPrefix = \"${local.resource_prefix}-app-tokens\"") + || gated_main.contains("secretPrefix = \"${local.resource_prefix}-app-tokens\""), + "the {name} vault still reports its prefix when gated:\n{gated_main}" + ); + + // The vault owns no cloud block, so its grants are the only thing it can + // gate — and nothing else in the module may pick the gate up. + let gated_types = gated_block_types(gated_main, VAULT_GATE); + assert!( + !gated_types.is_empty() && gated_types.iter().all(|found| found == grant_block), + "on {name} the gate belongs on the vault's {grant_block} grants and \ + nothing else, found {gated_types:?}:\n{gated_main}" + ); + // Whoever emitted it, nothing may keep granting over the vault's + // namespace once the deployer declines it. + assert_no_ungated_grant_over( + gated_main, + "${local.resource_prefix}-app-tokens-", + VAULT_GATE, + ); + + assert_gated_registration_list(gated_main, "var.input_vault_enabled"); + assert_terraform_valid(&gated, &format!("gated {name} vault stack")); + } +} + +/// The project-wide custom roles are shared across resources and carry their own +/// `var.gcp_manage_custom_roles` count. Adding one vault's gate would both +/// double-count them and delete roles the vault's siblings still hold, so only +/// the bindings beside them follow the gate. +#[test] +fn a_gated_gcp_vault_leaves_shared_custom_roles_alone() { + let module = render( + &vault_stack_with_custom_role(true), + TerraformTarget::Gcp, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("resource \"google_project_iam_custom_role\""), + "the fixture must actually render a custom role, or this proves nothing:\n{main}" + ); + let gated_types = gated_block_types(main, VAULT_GATE); + assert!( + !gated_types + .iter() + .any(|found| found == "google_project_iam_custom_role"), + "a shared custom role must not follow one vault's gate, found {gated_types:?}:\n{main}" + ); + assert!( + gated_types + .iter() + .any(|found| found == "google_project_iam_member"), + "the bindings beside it still follow the gate, found {gated_types:?}:\n{main}" + ); + assert!( + main.contains("count = var.gcp_manage_custom_roles ? 1 : 0"), + "the custom role keeps the count it already had:\n{main}" + ); + assert_terraform_valid(&module, "gated gcp vault stack with a custom role"); +} + +#[test] +fn an_ungated_prefix_only_vault_is_untouched() { + for (target, name) in [(TerraformTarget::Aws, "aws"), (TerraformTarget::Gcp, "gcp")] { + let main = &normalized(&render( + &vault_stack(false), + target, + StackSettings::default(), + )); + + assert!( + !main.contains(VAULT_GATE), + "nothing is gated, so no count belongs in the {name} module:\n{main}" + ); + assert_ungated_registration_list_is_a_plain_array(main); + } +} + +// -------------------------------------------------------------- Azure vault + +/// Azure is the one cloud where the vault is a real resource, and every role +/// assignment names it twice — once as `scope`, once inside the `uuidv5` seed +/// that derives the assignment's name. The seed is a raw interpolation, so it +/// is the reference most likely to be left behind. +#[test] +fn a_gated_azure_vault_indexes_both_references_in_every_role_assignment() { + let module = render( + &azure_vault_stack(true), + TerraformTarget::Azure, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("resource \"azurerm_role_assignment\""), + "the fixture must actually render a role assignment, or this test proves nothing:\n{main}" + ); + assert!( + main.contains(VAULT_GATE), + "the vault must be created only when the deployer says yes:\n{main}" + ); + assert!( + main.contains("scope = azurerm_key_vault.app_tokens[0].id"), + "the assignment scopes to the counted vault:\n{main}" + ); + assert!( + main.contains("vault-role-assign:${azurerm_key_vault.app_tokens[0].id}"), + "the uuidv5 seed must index too, not just the scope:\n{main}" + ); + assert!( + !main.contains("azurerm_key_vault.app_tokens."), + "no attribute may be read off the vault without an index:\n{main}" + ); + assert_gated_registration_list(main, "var.input_vault_enabled"); + assert_terraform_valid(&module, "gated azure vault stack"); +} + +/// The random suffix feeding the vault's name is a local value with no cloud +/// footprint. Leaving it uncounted is what keeps the vault's reference to it +/// unindexed, so the two decisions have to stay in step. +#[test] +fn a_gated_azure_vault_leaves_its_name_suffix_uncounted() { + let module = render( + &azure_vault_stack(true), + TerraformTarget::Azure, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + main.contains("random_id.app_tokens_name_suffix.hex"), + "the vault name reads the suffix directly:\n{main}" + ); + assert!( + !main.contains("random_id.app_tokens_name_suffix[0]"), + "an uncounted suffix must not be indexed:\n{main}" + ); +} + +#[test] +fn an_ungated_azure_vault_is_untouched() { + let module = render( + &azure_vault_stack(false), + TerraformTarget::Azure, + StackSettings::default(), + ); + let main = &normalized(&module); + + assert!( + !main.contains("azurerm_key_vault.app_tokens[0]"), + "no indexing on an ungated vault:\n{main}" + ); + assert!( + main.contains("scope = azurerm_key_vault.app_tokens.id") + && main.contains("vault-role-assign:${azurerm_key_vault.app_tokens.id}"), + "references stay unindexed:\n{main}" + ); + assert!( + !main.contains(VAULT_GATE), + "nothing is gated, so no count belongs in the module:\n{main}" + ); + assert_ungated_registration_list_is_a_plain_array(main); +} From 76eed055131f6a734c451496e6e3182e6b7272c3 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 18:12:36 +0300 Subject: [PATCH 02/12] fix(preflights): reject a gated resource inside a sibling's secret namespace Vault and postgres data grants are name-prefix scoped, so a grant on 'app' covers every secret under 'app-*', including a sibling 'app-config'. Declining 'app-config' withdraws its own grants but not the sibling's, leaving the declined namespace readable and writable. Refuse the gate unless the sibling follows the same input, or the ids are renamed apart. --- .../compile_time/resource_enabled_valid.rs | 181 +++++++++++++++++- 1 file changed, 178 insertions(+), 3 deletions(-) diff --git a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs index 4030099cb..64c357193 100644 --- a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs +++ b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs @@ -8,9 +8,11 @@ //! deployer variable that exists on the target and always holds a real //! boolean. //! -//! Two rules cover what a gate cannot reach on its own: a `"*"`-scoped grant is -//! read straight off the profile and keeps its access after the resource is gone, -//! and a dependent of a gated resource looks up outputs that will not be there. +//! Three rules cover what a gate cannot reach on its own: a `"*"`-scoped grant +//! is read straight off the profile and keeps its access after the resource is +//! gone, a dependent of a gated resource looks up outputs that will not be +//! there, and a sibling whose name-prefix grants cover the gated resource's +//! secret namespace keeps that namespace reachable after a deployer says no. use crate::error::Result; use crate::mutations::secrets_vault::SECRETS_VAULT_ID; @@ -180,6 +182,7 @@ impl CompileTimeCheck for ResourceEnabledValidCheck { } errors.extend(dependents_of_gated_resources(stack)); + errors.extend(gated_resources_inside_a_sibling_namespace(stack)); if errors.is_empty() { Ok(CheckResult::success()) @@ -235,6 +238,60 @@ fn dependents_of_gated_resources(stack: &Stack) -> Vec { errors } +/// Resource types whose data-plane grants cover a name prefix rather than an +/// exact resource: `vault/data-{read,write}` and `postgres/data-access` bind +/// secret access to every name under `{resource}-*`. AWS keeps the two in +/// different services, but GCP stores both kinds of secret in Secret Manager +/// under the same naming scheme, so the two types form one namespace family. +const NAME_PREFIX_GRANTED_TYPES: &[&str] = &["vault", "postgres"]; + +/// Rejects a gated resource whose secret namespace stays reachable through a +/// sibling's grants after the deployer declines it. +/// +/// Ids may contain hyphens, so `app` and `app-config` are distinct resources +/// whose namespaces nest: a grant on `app` covers everything under `app-*`, +/// including all of `app-config`'s secrets. Declining `app-config` withdraws +/// its own grants but not the sibling's, so the declined namespace stays +/// readable and writable. The rule fires whether or not such a grant exists +/// yet: ids cannot be renamed once deployments exist, so a stack that ships +/// this pair is one `.link()` away from an overlap nobody can fix. +fn gated_resources_inside_a_sibling_namespace(stack: &Stack) -> Vec { + let mut errors = Vec::new(); + for (resource_id, entry) in stack.resources() { + let Some(input_id) = entry.enabled_when.as_deref() else { + continue; + }; + if !NAME_PREFIX_GRANTED_TYPES.contains(&entry.config.resource_type().as_ref()) { + continue; + } + + for (sibling_id, sibling) in stack.resources() { + if !NAME_PREFIX_GRANTED_TYPES.contains(&sibling.config.resource_type().as_ref()) { + continue; + } + if !resource_id.starts_with(&format!("{sibling_id}-")) { + continue; + } + // Gated on the same input, the two exist or vanish together, so no + // grant survives a namespace it covers. + if sibling.enabled_when.as_deref() == Some(input_id) { + continue; + } + + errors.push(format!( + "Resource '{resource_id}' is enabled by input '{input_id}', but its id extends \ + '{sibling_id}', and {} data grants are name-prefix scoped: a grant on \ + '{sibling_id}' covers every secret named '{sibling_id}-*', which contains all of \ + '{resource_id}'s. A deployer who says no would still leave '{resource_id}'s \ + namespace readable and writable through '{sibling_id}'. Gate '{sibling_id}' on \ + '{input_id}' too, or rename one of them so neither id extends the other", + sibling.config.resource_type() + )); + } + } + errors +} + #[cfg(test)] mod tests { use super::*; @@ -564,6 +621,124 @@ mod tests { .is_empty()); } + /// A boolean deployer input named `id`, for stacks that gate on more than one. + fn named_input(id: &str) -> StackInputDefinition { + let mut input = boolean_input(); + input.id = id.to_string(); + input + } + + /// Vault grants cover `{id}-*`, so `app`'s grant reads all of `app-config`'s + /// secrets and declining `app-config` withdraws nothing. + #[tokio::test] + async fn rejects_a_gated_vault_inside_an_ungated_siblings_namespace() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("configEnabled")]) + .add( + Vault::new("app".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Vault::new("app-config".to_string()).build(), + ResourceLifecycle::Frozen, + "configEnabled", + ) + .build(); + + let errors = errors_for(stack).await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("its id extends 'app'"), "{errors:?}"); + assert!(errors[0].contains("rename one of them"), "{errors:?}"); + } + + /// Two gates mean two independent answers, and only one of them removes the + /// covering grant. + #[tokio::test] + async fn rejects_sibling_namespaces_gated_on_different_inputs() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("appEnabled"), named_input("configEnabled")]) + .add_enabled_when( + Vault::new("app".to_string()).build(), + ResourceLifecycle::Frozen, + "appEnabled", + ) + .add_enabled_when( + Vault::new("app-config".to_string()).build(), + ResourceLifecycle::Frozen, + "configEnabled", + ) + .build(); + + let errors = errors_for(stack).await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("'app-config'"), "{errors:?}"); + assert!(errors[0].contains("Gate 'app' on 'configEnabled'"), "{errors:?}"); + } + + /// One answer creates or removes both, so the covering grant never outlives + /// the namespace it covers. + #[tokio::test] + async fn accepts_sibling_namespaces_gated_on_the_same_input() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("featureEnabled")]) + .add_enabled_when( + Vault::new("app".to_string()).build(), + ResourceLifecycle::Frozen, + "featureEnabled", + ) + .add_enabled_when( + Vault::new("app-config".to_string()).build(), + ResourceLifecycle::Frozen, + "featureEnabled", + ) + .build(); + + assert!(errors_for(stack).await.is_empty()); + } + + /// `app2` is not under `app-*`: the wildcard requires the hyphen, so only a + /// hyphen extension nests namespaces. + #[tokio::test] + async fn accepts_sibling_ids_that_do_not_extend_each_other() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("appEnabled")]) + .add( + Vault::new("app2".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Vault::new("app".to_string()).build(), + ResourceLifecycle::Frozen, + "appEnabled", + ) + .build(); + + assert!(errors_for(stack).await.is_empty()); + } + + /// GCP stores postgres connection secrets and vault secrets in Secret + /// Manager under the same naming scheme, so the family crosses the two + /// types: a postgres named `db` covers a vault named `db-tokens`. + #[tokio::test] + async fn rejects_a_gated_vault_inside_a_postgres_namespace() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("tokensEnabled")]) + .add( + alien_core::Postgres::new("db".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Vault::new("db-tokens".to_string()).build(), + ResourceLifecycle::Frozen, + "tokensEnabled", + ) + .build(); + + let errors = errors_for(stack).await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("postgres data grants"), "{errors:?}"); + } + #[tokio::test] async fn ungated_stacks_skip_the_check_entirely() { let stack = Stack::new("test-stack".to_string()) From b1cce90e7d71775f0e2c135d977818f1130ae145 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 18:12:37 +0300 Subject: [PATCH 03/12] test(cloudformation): gate a vault id a customer could actually gate The fixture gated 'secrets', the reserved deployment secrets vault the preflight refuses. Rename it to the id the grant-scoped tests already use. --- .../generator/enabled_vault_queue_tests.rs | 30 +++++++++---------- ..._tests__enabled_gated_queue_and_vault.snap | 10 +++---- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs index baa10e3fa..50ca5bf45 100644 --- a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs +++ b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs @@ -38,6 +38,15 @@ fn render(stack: &Stack, description: &str) -> CfTemplate { const QUEUE_CONDITION: &str = "InputQueueEnabledIsTrue"; const VAULT_CONDITION: &str = "InputVaultEnabledIsTrue"; +/// A vault id a customer could actually gate. `secrets` is the reserved +/// deployment secrets vault, auto-wired to workers after validation, and the +/// preflight refuses to gate it. +const VAULT_ID: &str = "app-tokens"; + +/// The Parameter Store namespace the vault's grants are written against, exactly +/// as it appears inside the rendered `Fn::Sub`. +const VAULT_NAMESPACE: &str = "${AWS::StackName}-app-tokens-"; + fn gate_inputs() -> Vec { vec![ gate_input("queueEnabled", "queue", "Whether to create the queue."), @@ -54,7 +63,7 @@ fn base() -> StackBuilder { .permission( "execution", PermissionProfile::new() - .resource("secrets", ["vault/data-read"]) + .resource(VAULT_ID, ["vault/data-read"]) .resource("jobs", ["queue/data-write"]), ) .add( @@ -65,7 +74,7 @@ fn base() -> StackBuilder { fn stack(gated: bool) -> Stack { let queue = Queue::new("jobs".to_string()).build(); - let vault = Vault::new("secrets".to_string()).build(); + let vault = Vault::new(VAULT_ID.to_string()).build(); let builder = base(); if gated { builder @@ -215,7 +224,7 @@ fn a_gated_vault_gates_the_iam_policies_that_are_its_only_resources() { && resource .properties .get("PolicyName") - .map(|name| format!("{name:?}").contains("secrets")) + .map(|name| format!("{name:?}").contains(VAULT_ID)) .unwrap_or(false)), "no vault policy may survive the gate being off" ); @@ -234,7 +243,7 @@ fn declined_resources_leave_no_registration_entry() { vec![ "execution-sa".to_string(), "jobs".to_string(), - "secrets".to_string() + VAULT_ID.to_string() ], "everything registers when the deployer says yes" ); @@ -276,7 +285,7 @@ fn the_outputs_fallback_omits_declined_resources() { vec![ "execution-sa".to_string(), "jobs".to_string(), - "secrets".to_string() + VAULT_ID.to_string() ], "everything registers when the deployer says yes" ); @@ -347,7 +356,7 @@ fn an_ungated_stack_gains_no_conditions() { vec![ "execution-sa".to_string(), "jobs".to_string(), - "secrets".to_string() + VAULT_ID.to_string() ], ); } @@ -366,15 +375,6 @@ fn the_gated_template_snapshot_stays_reviewable() { // ------------------------------------------- the grant, whoever emitted it -/// A vault id a customer could actually gate. `secrets` — which `stack()` above -/// still uses — is the reserved deployment secrets vault, auto-wired to workers -/// after validation, and the preflight refuses to gate it. -const VAULT_ID: &str = "app-tokens"; - -/// The Parameter Store namespace the vault's grants are written against, exactly -/// as it appears inside the rendered `Fn::Sub`. -const VAULT_NAMESPACE: &str = "${AWS::StackName}-app-tokens-"; - /// The vault grant scoped to the resource, which is the shape `.link()` authors. /// A `"*"`-scoped grant is deliberately excluded: it lands on the role through /// `stack_permission_sets` and is stack-wide by design, which is why diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap index 0fcd78d2b..70432e49c 100644 --- a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap @@ -150,11 +150,11 @@ Resources: - Key: resource-type Value: queue Condition: InputQueueEnabledIsTrue - SecretsExecutionSaRoleVaultPermission00: + AppTokensExecutionSaRoleVaultPermission00: Type: AWS::IAM::Policy Properties: PolicyName: - Fn::Sub: ${AWS::StackName}-secrets-vault-0-0 + Fn::Sub: ${AWS::StackName}-app-tokens-vault-0-0 PolicyDocument: Version: 2012-10-17 Statement: @@ -162,7 +162,7 @@ Resources: - ssm:GetParameter Effect: Allow Resource: - - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}-secrets-* + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}-app-tokens-* Sid: VaultDataRead Roles: - Ref: ExecutionSaRole @@ -263,7 +263,7 @@ Outputs: - ${entry} - entry: Fn::ToJsonString: - id: secrets + id: app-tokens type: vault importData: accountId: @@ -271,7 +271,7 @@ Outputs: region: Ref: AWS::Region parameterPrefix: - Fn::Sub: ${AWS::StackName}-secrets + Fn::Sub: ${AWS::StackName}-app-tokens - Ref: AWS::NoValue - ']' Description: Deployment registration resources JSON. From 06796a592ce2a8c249428057beb1a07f43978b0b Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 19:24:38 +0300 Subject: [PATCH 04/12] fix(preflights): flag family wildcard grants over a gated secret namespace A '*'-scoped vault or postgres data grant binds by secret-name prefix over the whole stack, whichever type wrote the secret, so on GCP a postgres grant keeps reading a declined vault's namespace. The wildcard rule now flags either family member's grant when the gated resource is in the family. --- .../compile_time/resource_enabled_valid.rs | 98 ++++++++++++++++--- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs index 64c357193..f22703fb6 100644 --- a/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs +++ b/crates/alien-preflights/src/compile_time/resource_enabled_valid.rs @@ -95,16 +95,36 @@ impl CompileTimeCheck for ResourceEnabledValidCheck { // `ServiceAccount::from_permission_profile` builds the runtime role from the // profile's "*" key alone. It never sees the resource list, so gating the - // resource cannot take a wildcard grant back off the role. + // resource cannot take a wildcard grant back off the role. For a resource + // in the secret-namespace family the net is wider: a '*'-scoped vault or + // postgres data grant binds by secret-name prefix over the whole stack, + // whichever type wrote the secret, so either family member's grant keeps + // the gated resource's namespace reachable after a deployer says no. // Grant ids use the permission namespace, which is not always the // raw resource type; a raw-type prefix would let a '*' grant for a // remapped type slip past this net. - let permission_set_prefix = format!( + let own_prefix = format!( "{}/", crate::mutations::management_permission_profile::permission_resource_type( resource_type.as_ref(), ) ); + let flagged_prefixes: Vec = + if NAME_PREFIX_GRANTED_TYPES.contains(&resource_type.as_ref()) { + NAME_PREFIX_GRANTED_TYPES + .iter() + .map(|family_type| { + format!( + "{}/", + crate::mutations::management_permission_profile::permission_resource_type( + family_type, + ) + ) + }) + .collect() + } else { + vec![own_prefix.clone()] + }; let named_profiles = stack .permissions .profiles @@ -122,19 +142,34 @@ impl CompileTimeCheck for ResourceEnabledValidCheck { }; for grant in wildcard_grants { - if !grant.id().starts_with(&permission_set_prefix) { + if !flagged_prefixes + .iter() + .any(|prefix| grant.id().starts_with(prefix)) + { continue; } - errors.push(format!( - "Profile '{profile_name}' grants '{}' at the '*' scope while resource \ - '{resource_id}' is enabled by input '{input_id}'. A '*' grant is read \ - off the profile alone, so it stays on the runtime role after a deployer \ - says no and leaves the access without the resource. Remove the '*' grant \ - and .link() '{resource_id}' from the compute resource instead, which \ - scopes the grant to that resource so it follows the gate", - grant.id() - )); + if grant.id().starts_with(&own_prefix) { + errors.push(format!( + "Profile '{profile_name}' grants '{}' at the '*' scope while resource \ + '{resource_id}' is enabled by input '{input_id}'. A '*' grant is read \ + off the profile alone, so it stays on the runtime role after a deployer \ + says no and leaves the access without the resource. Remove the '*' grant \ + and .link() '{resource_id}' from the compute resource instead, which \ + scopes the grant to that resource so it follows the gate", + grant.id() + )); + } else { + errors.push(format!( + "Profile '{profile_name}' grants '{}' at the '*' scope while resource \ + '{resource_id}' is enabled by input '{input_id}'. That grant binds by \ + secret-name prefix over the whole stack, which includes \ + '{resource_id}'s namespace, and a '*' grant stays on the runtime role \ + after a deployer says no. Scope it to its own resource with .link() so \ + declining '{resource_id}' leaves nothing over its secrets", + grant.id() + )); + } } } @@ -278,13 +313,23 @@ fn gated_resources_inside_a_sibling_namespace(stack: &Stack) -> Vec { continue; } + // The reserved secrets vault cannot be gated, so offering to gate it + // would send the deployer down a path the SECRETS_VAULT_ID guard + // rejects; only the rename remedy applies there. + let remedy = if sibling_id == SECRETS_VAULT_ID { + format!("rename '{resource_id}' so its id does not extend '{sibling_id}'") + } else { + format!( + "Gate '{sibling_id}' on '{input_id}' too, or rename one of them so neither id \ + extends the other" + ) + }; errors.push(format!( "Resource '{resource_id}' is enabled by input '{input_id}', but its id extends \ '{sibling_id}', and {} data grants are name-prefix scoped: a grant on \ '{sibling_id}' covers every secret named '{sibling_id}-*', which contains all of \ '{resource_id}'s. A deployer who says no would still leave '{resource_id}'s \ - namespace readable and writable through '{sibling_id}'. Gate '{sibling_id}' on \ - '{input_id}' too, or rename one of them so neither id extends the other", + namespace readable and writable through '{sibling_id}'. {remedy}", sibling.config.resource_type() )); } @@ -524,6 +569,31 @@ mod tests { assert!(errors[0].contains("at the '*' scope"), "{errors:?}"); } + /// GCP stores both family types' secrets in Secret Manager and their stack + /// bindings match on the stack prefix alone, so a '*'-scoped postgres grant + /// keeps reading a declined vault's namespace. The net covers the + /// cross-type pair too. + #[tokio::test] + async fn rejects_a_wildcard_family_grant_over_a_gated_vault() { + let stack = Stack::new("test-stack".to_string()) + .inputs(vec![named_input("vaultEnabled")]) + .permission( + "execution", + PermissionProfile::new().global(["postgres/data-access"]), + ) + .add_enabled_when( + Vault::new("app-tokens".to_string()).build(), + ResourceLifecycle::Frozen, + "vaultEnabled", + ) + .build(); + + let errors = errors_for(stack).await; + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("postgres/data-access"), "{errors:?}"); + assert!(errors[0].contains("secret-name prefix"), "{errors:?}"); + } + /// Builds a stack whose bucket depends on a gated store through an explicit /// entry dependency, with the bucket's own gate supplied by the caller. Both /// are plain data resources, so only the dependency rule is under test. From 0e0a3e7c680b017e632f611b39f048f846a7c1e1 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 19:24:39 +0300 Subject: [PATCH 05/12] test(cloudformation): pin the queue's resource-scoped grant to its gate --- .../generator/enabled_vault_queue_tests.rs | 7 +++-- ..._tests__enabled_gated_queue_and_vault.snap | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs index 50ca5bf45..7614b8852 100644 --- a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs +++ b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs @@ -186,8 +186,11 @@ fn a_gated_queue_is_created_only_when_the_deployer_says_yes() { assert_eq!(queue.condition.as_deref(), Some(QUEUE_CONDITION)); assert_eq!( resources_conditioned_on(&template, QUEUE_CONDITION), - vec!["Jobs".to_string()], - "the queue owns exactly one resource, and it is gated" + vec![ + "Jobs".to_string(), + "JobsExecutionSaRoleQueuePermission00".to_string(), + ], + "the queue owns the SQS queue and its resource-scoped grant, both gated" ); } diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap index 70432e49c..4dd16e992 100644 --- a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap @@ -150,6 +150,32 @@ Resources: - Key: resource-type Value: queue Condition: InputQueueEnabledIsTrue + JobsExecutionSaRoleQueuePermission00: + Type: AWS::IAM::Policy + Properties: + PolicyName: + Fn::Sub: ${AWS::StackName}-jobs-queue-0-0 + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - sqs:ChangeMessageVisibility + - sqs:DeleteMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + - sqs:SendMessage + Effect: Allow + Resource: + Fn::GetAtt: + - Jobs + - Arn + Sid: SendSqsMessages + Roles: + - Ref: ExecutionSaRole + DependsOn: + - Jobs + - ExecutionSaRole + Condition: InputQueueEnabledIsTrue AppTokensExecutionSaRoleVaultPermission00: Type: AWS::IAM::Policy Properties: From 90676cd7b0842399c62a532370bc15f3523dda14 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Mon, 20 Jul 2026 19:27:54 +0300 Subject: [PATCH 06/12] test(terraform): snapshot the full gated module --- .../generator/enabled_vault_queue_tests.rs | 26 ++ ...rs__enabled_gated_queue_and_vault_aws.snap | 354 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_queue_and_vault_aws.snap diff --git a/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs index c9fed3689..bc8b54834 100644 --- a/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs +++ b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs @@ -626,3 +626,29 @@ fn an_ungated_azure_vault_is_untouched() { ); assert_ungated_registration_list_is_a_plain_array(main); } + +/// The complete gated module as rendered text, so reviewers read the same +/// artifact a customer's security team does — the CloudFormation suite commits +/// the equivalent YAML snapshot. +#[test] +fn the_gated_module_snapshot_stays_reviewable() { + let builder = permissioned( + Stack::new("gated-stack".to_string()).inputs(gate_inputs()), + "jobs", + &["queue/data-write"], + ) + .permission( + "reader", + PermissionProfile::new().resource("app-tokens", ["vault/data-read"]), + ) + .add( + ServiceAccount::new("reader-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ); + let module = render( + &add_vault(add_queue(builder, true), true).build(), + TerraformTarget::Aws, + StackSettings::default(), + ); + super::helpers::snapshot_module("enabled_gated_queue_and_vault_aws", &module); +} diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_queue_and_vault_aws.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_queue_and_vault_aws.snap new file mode 100644 index 000000000..cd79216f3 --- /dev/null +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_queue_and_vault_aws.snap @@ -0,0 +1,354 @@ +--- +source: crates/alien-terraform/tests/generator/helpers.rs +expression: buf +--- +=== versions.tf === +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} + +=== variables.tf === +variable "resource_prefix" { + type = string + description = "Optional stable physical resource prefix. Leave empty to generate one." + default = "" + + validation { + condition = var.resource_prefix == "" || (can(regex("^[a-z][a-z0-9-]{1,38}[a-z0-9]$", var.resource_prefix)) && length(regexall("--", var.resource_prefix)) == 0) + error_message = "resource_prefix must be 3-40 characters: lowercase letters, numbers, and hyphens; start with a letter; end with a letter or number; and not contain consecutive hyphens." + } +} + +variable "name" { + type = string + description = "Human-readable application name shown in setup and cloud IAM review metadata." +} + +variable "token" { + type = string + description = "Install token from the application setup page. This is the same token used by the deploy CLI --token flag." + sensitive = true +} + +variable "management_url" { + type = string + description = "Optional management endpoint used by pull-style runtimes." + default = "" +} + +variable "deployment_model" { + type = string + description = "How runtime updates are delivered after setup." + default = "push" + + validation { + condition = contains(["push", "pull"], var.deployment_model) + error_message = "deployment_model must be one of: push, pull." + } +} + +variable "advanced_settings_json" { + type = string + description = "Advanced JSON-encoded deployment settings. Most installations should use the typed variables in this module instead." + default = "{}" + sensitive = true +} + +variable "advanced_settings_overlay_json" { + type = string + description = "JSON-encoded deployment settings merged over the package defaults. Use this for partial advanced-setting overrides that must preserve generated defaults such as compute selections." + default = "{}" + sensitive = true +} + +variable "updates_mode" { + type = string + description = "How application updates are delivered after setup." + default = "auto" + + validation { + condition = contains(["auto", "approval-required"], var.updates_mode) + error_message = "updates_mode must be one of: auto, approval-required." + } +} + +variable "telemetry_mode" { + type = string + description = "How logs, metrics, and traces are collected." + default = "auto" + + validation { + condition = contains(["off", "auto", "approval-required"], var.telemetry_mode) + error_message = "telemetry_mode must be one of: off, auto, approval-required." + } +} + +variable "heartbeats_mode" { + type = string + description = "Whether runtime health checks are enabled." + default = "on" + + validation { + condition = contains(["off", "on"], var.heartbeats_mode) + error_message = "heartbeats_mode must be one of: off, on." + } +} + +variable "aws_region" { + type = string + description = "AWS region used by the AWS provider." + default = "us-east-1" +} + +variable "managing_role_arn" { + type = string + description = "ARN of the management identity allowed to assume setup-created roles." + default = "" +} + +variable "managing_account_id" { + type = string + description = "AWS account ID that hosts application container images. Empty disables scoped cross-account image-pull grants." + default = "" +} + +variable "input_queue_enabled" { + type = bool + description = "queue Whether to create the queue." + default = true +} + +variable "input_vault_enabled" { + type = bool + description = "vault Whether to create the vault." + default = true +} + +=== providers.tf === +provider "aws" { + region = var.aws_region +} + +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +=== resource_prefix.tf === +resource "random_id" "resource_prefix" { + byte_length = 4 +} + +=== locals.tf === +locals { + resource_prefix = var.resource_prefix == "" ? format("a%s", random_id.resource_prefix.hex) : var.resource_prefix + deployment_name = var.name + deployment_platform = "aws" + deployment_target = "aws" + deployment_region = data.aws_region.current.region + deployment_management_config = null + advanced_settings = merge(jsondecode(var.advanced_settings_json), jsondecode(var.advanced_settings_overlay_json)) + deployment_settings = merge(local.advanced_settings, { deploymentModel = var.deployment_model, updates = var.updates_mode, telemetry = var.telemetry_mode, heartbeats = var.heartbeats_mode }) + deployment_resources = concat([{ id = "execution-sa", type = "service-account", importData = { roleName = aws_iam_role.execution_sa.name, roleArn = aws_iam_role.execution_sa.arn, stackPermissionsApplied = true } }], [{ id = "reader-sa", type = "service-account", importData = { roleName = aws_iam_role.reader_sa.name, roleArn = aws_iam_role.reader_sa.arn, stackPermissionsApplied = true } }], var.input_queue_enabled ? [{ id = "jobs", type = "queue", importData = { queueName = aws_sqs_queue.jobs[0].name, queueUrl = aws_sqs_queue.jobs[0].url, queueArn = aws_sqs_queue.jobs[0].arn } }] : [], var.input_vault_enabled ? [{ id = "app-tokens", type = "vault", importData = { accountId = data.aws_caller_identity.current.account_id, region = data.aws_region.current.region, parameterPrefix = "${local.resource_prefix}-app-tokens" } }] : []) +} + +=== execution_sa.tf === +resource "aws_iam_role" "execution_sa" { + name = length(format("%s-%s", local.resource_prefix, "execution-sa")) <= 64 ? format("%s-%s", local.resource_prefix, "execution-sa") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "execution-sa"), 0, 55), substr(sha1(format("%s-%s", local.resource_prefix, "execution-sa")), 0, 8)) + assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Principal = { Service = ["codebuild.amazonaws.com", "ec2.amazonaws.com", "lambda.amazonaws.com"] }, Action = "sts:AssumeRole" }] }) + tags = { + "managed-by" = "setup" + deployment = local.resource_prefix + resource = "execution-sa" + "resource-type" = "service-account" + } +} + +=== reader_sa.tf === +resource "aws_iam_role" "reader_sa" { + name = length(format("%s-%s", local.resource_prefix, "reader-sa")) <= 64 ? format("%s-%s", local.resource_prefix, "reader-sa") : format("%s-%s", substr(format("%s-%s", local.resource_prefix, "reader-sa"), 0, 55), substr(sha1(format("%s-%s", local.resource_prefix, "reader-sa")), 0, 8)) + assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Principal = { Service = ["codebuild.amazonaws.com", "ec2.amazonaws.com", "lambda.amazonaws.com"] }, Action = "sts:AssumeRole" }] }) + tags = { + "managed-by" = "setup" + deployment = local.resource_prefix + resource = "reader-sa" + "resource-type" = "service-account" + } +} + +=== jobs.tf === +resource "aws_sqs_queue" "jobs" { + count = var.input_queue_enabled ? 1 : 0 + name = "${local.resource_prefix}-jobs" + sqs_managed_sse_enabled = true + visibility_timeout_seconds = 30 + message_retention_seconds = 345600 + tags = { + "managed-by" = "setup" + deployment = local.resource_prefix + resource = "jobs" + "resource-type" = "queue" + } +} + +=== app_tokens.tf === +resource "aws_iam_role_policy" "reader_sa_vault_app_tokens_set_0" { + count = var.input_vault_enabled ? 1 : 0 + name = "access-app-tokens-vault-data-read" + role = aws_iam_role.reader_sa.id + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "VaultDataRead", Effect = "Allow", Action = ["ssm:GetParameter"], Resource = ["arn:aws:ssm:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:parameter/${local.resource_prefix}-app-tokens-*"] }] }) +} + +=== registration.tf === +resource "terraform_data" "deployment_registration" { + input = { + platform = local.deployment_platform + token = var.token + name = var.name + resource_prefix = local.resource_prefix + setup_target = "" + setup_import_format_version = 1 + setup_fingerprint = "" + setup_fingerprint_version = 0 + management_url = var.management_url + management_config = local.deployment_management_config + stack_settings = local.deployment_settings + resources = local.deployment_resources + inputValues = { + queueEnabled = var.input_queue_enabled + vaultEnabled = var.input_vault_enabled + } + } + depends_on = [ + aws_iam_role.execution_sa, + aws_iam_role.reader_sa, + aws_sqs_queue.jobs, + aws_iam_role_policy.reader_sa_vault_app_tokens_set_0 + ] +} + +=== outputs.tf === +output "deployment_target" { + value = "aws" + description = "Terraform module target." +} + +output "deployment_resource_prefix" { + value = local.resource_prefix + description = "Physical resource prefix." +} + +output "deployment_platform" { + value = local.deployment_platform + description = "Target platform." +} + +output "deployment_region" { + value = local.deployment_region + description = "Target cloud region or location." +} + +output "deployment_setup_target" { + value = "" + description = "Setup target." +} + +output "deployment_setup_import_format_version" { + value = 1 + description = "Setup registration payload format version." +} + +output "deployment_setup_fingerprint" { + value = "" + description = "Setup compatibility fingerprint." +} + +output "deployment_setup_fingerprint_version" { + value = 0 + description = "Setup fingerprint algorithm version." +} + +output "deployment_management_config" { + value = jsonencode(local.deployment_management_config) + description = "Deployment registration management configuration JSON." +} + +output "deployment_stack_settings" { + value = jsonencode(local.deployment_settings) + description = "Deployment registration settings JSON." + sensitive = true +} + +output "deployment_resources" { + value = jsonencode(local.deployment_resources) + description = "Deployment registration resource metadata JSON." +} + +=== README.md === +# Deployment setup - gated-stack + +Target: `aws`. + +This module creates setup-owned infrastructure, grants the management access needed after setup, and prepares deployment registration metadata. Review the generated `.tf` files before applying; each resource file maps to one setup resource. + +## Inputs + +Required: + +- `token`: install token from the setup page. +- `name`: deployment name to include in the registration metadata. + +Common optional settings: + +- `resource_prefix`: stable physical-name prefix. Leave empty to generate one. +- `management_url`: optional management endpoint used by pull-style runtimes. +- `deployment_model`: `push` or `pull`. +- `updates_mode`: `auto` or `approval-required`. +- `telemetry_mode`: `off`, `auto`, or `approval-required`. +- `heartbeats_mode`: `off` or `on`. +- `advanced_settings_json`: complete advanced deployment settings JSON. Most installs should keep the generated default. +- `advanced_settings_overlay_json`: partial advanced settings merged over package defaults, preserving generated values such as compute selections. + +AWS settings: + +- `aws_region`: AWS region used by the provider. +- `managing_role_arn`: management identity allowed to assume setup-created roles. +- `managing_account_id`: account that hosts application container images. Empty disables scoped cross-account image-pull grants. + +Application inputs: +- `input_queue_enabled`: queue (optional). Whether to create the queue. +- `input_vault_enabled`: vault (optional). Whether to create the vault. + +## Run + +Use your organization's normal backend and approval workflow. A typical local review looks like: + +```bash +export TF_VAR_name="gated-stack" +export TF_VAR_token="..." +terraform init +terraform validate +terraform plan -out=tfplan +terraform apply tfplan +``` + +## Registration + +This module exposes `deployment_management_config`, `deployment_stack_settings`, and `deployment_resources` outputs for registration flows managed outside Terraform. + +## Outputs + +- `deployment_management_config`: management endpoint and credential-boundary metadata. +- `deployment_stack_settings`: deployment settings JSON assembled from typed variables, package defaults, and advanced-setting overlays. +- `deployment_resources`: setup-owned resource metadata handed to the deployment runtime. +- `deployment_id` and `deployment_token`: emitted only when Terraform performs registration. From 3e288fe697943f24f3930bea03a2f9081643fa49 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Wed, 22 Jul 2026 15:26:37 +0300 Subject: [PATCH 07/12] fix(setup): drop declined resources' grants from permission profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip_declined_resources removed a deployer-declined gated resource from the desired stack but left its resource-scoped entry in every permission profile. On GCP the service-account controller derives project-level Vertex bindings straight from the profile (IAM can't scope to a sub-resource), so a declined resource's grant was still applied at deploy time — a runtime fail-open of the .enabled() gate. Prune the entry from every profile so no runtime consumer re-grants it; the "*" wildcard, which is not resource-scoped, stays. --- crates/alien-deployment/src/pending.rs | 66 +++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index c663923a2..80358cd7f 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -156,6 +156,16 @@ pub fn strip_declined_resources( "The deployer declined this gated resource; it leaves the desired stack" ); stack.resources.shift_remove(resource_id); + // A declined resource keeps no grant. Its resource-scoped entry must also + // leave every permission profile, or a runtime consumer that derives + // grants straight from the profile (the GCP service-account controller + // applies resource grants as project-level bindings, since Vertex can't + // scope IAM to a sub-resource) would re-grant it — the runtime twin of + // the setup emitter's enabled_when gate. The "*" wildcard is not + // resource-scoped, so it is left alone. + for profile in stack.permissions.profiles.values_mut() { + profile.0.shift_remove(resource_id.as_str()); + } } Ok(stack) @@ -249,8 +259,8 @@ fn environment_collection_context( mod tests { use super::*; use alien_core::{ - Kv, KubernetesClientConfig, Resource, ResourceLifecycle, ResourceStatus, ServiceAccount, - StackInputDefinition, StackResourceState, + Kv, KubernetesClientConfig, PermissionProfile, Resource, ResourceLifecycle, + ResourceStatus, ServiceAccount, StackInputDefinition, StackResourceState, }; fn imported_state_with(resource_id: &str, resource: Resource) -> StackState { @@ -313,6 +323,58 @@ mod tests { assert!(stripped.resources.contains_key("execution-sa")); } + /// A declined gated resource must also lose its resource-scoped grant from + /// every permission profile (see `strip_declined_resources` for why), while + /// the `"*"` wildcard survives. + #[test] + fn a_declined_resource_loses_its_permission_profile_grant() { + let stack = Stack::new("gated-stack".to_string()) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Kv::new("analytics".to_string()).build(), + ResourceLifecycle::Frozen, + "analyticsEnabled", + ) + .permission( + "execution", + PermissionProfile::new() + .resource("analytics", ["kv/write"]) + .resource("*", ["worker/invoke"]), + ) + .build(); + + // The import delivered the service account but not the gated store, so + // the store is the deployer's declined resource. + let state = imported_state_with( + "execution-sa", + Resource::new(ServiceAccount::new("execution-sa".to_string()).build()), + ); + + let stripped = strip_declined_resources(stack, &state, &Default::default()) + .expect("frozen rules never error"); + + assert!( + !stripped.resources.contains_key("analytics"), + "the declined resource leaves the desired stack" + ); + let profile = stripped + .permissions + .profiles + .get("execution") + .expect("the profile itself survives"); + assert!( + !profile.0.contains_key("analytics"), + "the declined resource's grant leaves the profile so no runtime consumer re-applies it" + ); + assert!( + profile.0.contains_key("*"), + "the wildcard grant is not resource-scoped and is untouched" + ); + } + /// An empty state means this runner creates the frozen resources itself /// (a direct deploy), so absence carries no answer and nothing is dropped. #[test] From 338f993e3e14cb9249bbb5221bc77f30c25d0457 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 15:25:55 +0300 Subject: [PATCH 08/12] test(deployment): assert kept grants survive the declined-resource strip The strip test only proved the "*" wildcard survived; add a kept sibling resource with its own resource-scoped grant and assert it stays, so the test proves the removal is exact-key and never sweeps a sibling. Also trim the vault supports_enabled_when docstrings and the queue/vault test docs to lead with the prefix-wildcard footgun instead of restating that the vault owns no block, and point the strip comment at the preflight that makes exact-key removal safe. --- .../generator/enabled_vault_queue_tests.rs | 5 +-- crates/alien-deployment/src/pending.rs | 38 +++++++++++++++---- .../alien-terraform/src/emitters/aws/vault.rs | 11 ++---- .../alien-terraform/src/emitters/gcp/vault.rs | 17 ++++----- .../generator/enabled_vault_queue_tests.rs | 16 +++----- 5 files changed, 48 insertions(+), 39 deletions(-) diff --git a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs index 7614b8852..5bc69795a 100644 --- a/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs +++ b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs @@ -233,9 +233,8 @@ fn a_gated_vault_gates_the_iam_policies_that_are_its_only_resources() { ); } -/// The invariant that matters most: registration runs the typed importer over -/// every entry it receives, so a declined resource has to be absent rather than -/// present-and-null. +/// Registration runs the typed importer over every entry it receives, so a +/// declined resource has to be absent rather than present-and-null. #[test] fn declined_resources_leave_no_registration_entry() { let template = render(&stack(true), "gated queue and vault"); diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index 80358cd7f..a02cb04aa 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -161,8 +161,11 @@ pub fn strip_declined_resources( // grants straight from the profile (the GCP service-account controller // applies resource grants as project-level bindings, since Vertex can't // scope IAM to a sub-resource) would re-grant it — the runtime twin of - // the setup emitter's enabled_when gate. The "*" wildcard is not - // resource-scoped, so it is left alone. + // the setup emitter's enabled_when gate. Removing the exact key suffices + // because `ResourceEnabledValidCheck` (alien-preflights) already rejects a + // `*`-scoped or sibling-namespace grant that could still cover a gated + // resource; the "*" wildcard here is not resource-scoped, so it is left + // alone. for profile in stack.permissions.profiles.values_mut() { profile.0.shift_remove(resource_id.as_str()); } @@ -323,9 +326,10 @@ mod tests { assert!(stripped.resources.contains_key("execution-sa")); } - /// A declined gated resource must also lose its resource-scoped grant from - /// every permission profile (see `strip_declined_resources` for why), while - /// the `"*"` wildcard survives. + /// A declined gated resource loses its resource-scoped grant from every + /// permission profile (see `strip_declined_resources` for why). The removal + /// is exact-key, so a kept resource's own grant and the `"*"` wildcard both + /// survive — the strip must not sweep a sibling. #[test] fn a_declined_resource_loses_its_permission_profile_grant() { let stack = Stack::new("gated-stack".to_string()) @@ -333,6 +337,10 @@ mod tests { ServiceAccount::new("execution-sa".to_string()).build(), ResourceLifecycle::Frozen, ) + .add( + Kv::new("events".to_string()).build(), + ResourceLifecycle::Frozen, + ) .add_enabled_when( Kv::new("analytics".to_string()).build(), ResourceLifecycle::Frozen, @@ -342,16 +350,26 @@ mod tests { "execution", PermissionProfile::new() .resource("analytics", ["kv/write"]) + .resource("events", ["kv/read"]) .resource("*", ["worker/invoke"]), ) .build(); - // The import delivered the service account but not the gated store, so - // the store is the deployer's declined resource. - let state = imported_state_with( + // The import delivered the service account and the ungated `events` + // store but not the gated `analytics` store, so `analytics` is the + // deployer's declined resource. + let mut state = imported_state_with( "execution-sa", Resource::new(ServiceAccount::new("execution-sa".to_string()).build()), ); + let mut events = StackResourceState::new_pending( + "kv".to_string(), + Resource::new(Kv::new("events".to_string()).build()), + Some(ResourceLifecycle::Frozen), + Vec::new(), + ); + events.status = ResourceStatus::Running; + state.resources.insert("events".to_string(), events); let stripped = strip_declined_resources(stack, &state, &Default::default()) .expect("frozen rules never error"); @@ -369,6 +387,10 @@ mod tests { !profile.0.contains_key("analytics"), "the declined resource's grant leaves the profile so no runtime consumer re-applies it" ); + assert!( + profile.0.contains_key("events"), + "a kept resource's own grant survives: the strip is exact-key, not a prefix sweep" + ); assert!( profile.0.contains_key("*"), "the wildcard grant is not resource-scoped and is untouched" diff --git a/crates/alien-terraform/src/emitters/aws/vault.rs b/crates/alien-terraform/src/emitters/aws/vault.rs index a4b3fc87a..6f37fff5c 100644 --- a/crates/alien-terraform/src/emitters/aws/vault.rs +++ b/crates/alien-terraform/src/emitters/aws/vault.rs @@ -113,14 +113,9 @@ impl TfEmitter for AwsVaultEmitter { ])) } - /// The vault has no Terraform block of its own — it is a Parameter Store - /// name prefix — and its import data is built entirely from `local` and - /// `data` values. So nothing here needs an index: the IAM policies grant - /// against a static `${local.resource_prefix}--*` pattern, never against - /// a resource address. - /// - /// They still take the gate. `ssm:GetParameter` is a data-plane read, and - /// the pattern is a prefix wildcard: resource ids may contain hyphens, so a + /// The IAM policies grant against a static `${local.resource_prefix}--*` + /// pattern, never a resource address, so nothing here needs an index. The + /// pattern is a prefix wildcard and resource ids may contain hyphens: a /// declined vault `app` leaves a grant over `-app-*`, which matches /// every parameter in a live sibling vault named `app-config`. Declining a /// vault has to withdraw the permission, not just the registration entry. diff --git a/crates/alien-terraform/src/emitters/gcp/vault.rs b/crates/alien-terraform/src/emitters/gcp/vault.rs index ce61b74a0..0e4611eeb 100644 --- a/crates/alien-terraform/src/emitters/gcp/vault.rs +++ b/crates/alien-terraform/src/emitters/gcp/vault.rs @@ -115,16 +115,13 @@ impl TfEmitter for GcpVaultEmitter { ])) } - /// The vault has no Terraform block of its own — it is a Secret Manager - /// name prefix — and its import data is built entirely from `local` and - /// `var` values. So nothing here needs an index: the role bindings are - /// project-scoped with a name-prefix condition, never a resource address. - /// - /// They still take the gate. `roles/secretmanager.secretAccessor` reads - /// secret payloads, and the condition matches on a prefix: resource ids may - /// contain hyphens, so a declined vault `app` leaves a grant over secrets - /// starting `-app-`, which covers a live sibling vault named - /// `app-config`. Only the bindings are gated — see `gate_bindings`. + /// The role bindings are project-scoped with a name-prefix condition, never + /// a resource address, so nothing here needs an index. + /// `roles/secretmanager.secretAccessor` reads secret payloads and the + /// condition matches on a prefix: resource ids may contain hyphens, so a + /// declined vault `app` leaves a grant over secrets starting `-app-`, + /// which covers a live sibling vault named `app-config`. Only the bindings + /// are gated — see `gate_bindings`. fn supports_enabled_when(&self) -> bool { true } diff --git a/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs index bc8b54834..37fc458fe 100644 --- a/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs +++ b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs @@ -437,16 +437,12 @@ fn assert_no_ungated_grant_over(main: &str, namespace: &str, gate: &str) { ); } -/// On AWS and GCP the vault owns no block of its own — it is a name prefix, and -/// its import data is built from `local` / `var` / `data` values. So there is -/// nothing to index, and the prefix it reports must not change. -/// -/// Its access policy is a different matter. The grant is a data-plane read -/// (`ssm:GetParameter`, `roles/secretmanager.secretAccessor`) over a prefix -/// wildcard, and resource ids may contain hyphens — so a declined vault `app` -/// left holding `-app-*` can read a live sibling vault `app-config`. -/// Declining a vault has to withdraw the permission, not just the registration -/// entry, which is also what the CloudFormation generator does. +/// The vault's access policy is a data-plane read (`ssm:GetParameter`, +/// `roles/secretmanager.secretAccessor`) over a prefix wildcard, and resource +/// ids may contain hyphens — so a declined vault `app` left holding +/// `-app-*` can read a live sibling vault `app-config`. Declining it has +/// to withdraw the permission, not just the registration entry (the same as the +/// CloudFormation generator). #[test] fn a_gated_prefix_only_vault_gates_the_access_policy_it_owns() { for (target, name, grant_block) in [ From 5a4c87a0301754c90eb4ce3d96bdad6032919f98 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:07:30 +0300 Subject: [PATCH 09/12] fix(setup): also drop declined resources' management-profile grants The declined-resource strip cleaned every named permission profile but not the separate management profile. An Extend/Override management profile is a second resource-scoped grant store the management role reads from, so a declined resource's entry there kept the management role a namespace grant the resource no longer backs. Strip it from the management profile too; Auto needs nothing, it is re-derived from the stripped resource set. --- crates/alien-deployment/src/pending.rs | 71 ++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index a02cb04aa..40ecf2406 100644 --- a/crates/alien-deployment/src/pending.rs +++ b/crates/alien-deployment/src/pending.rs @@ -1,7 +1,9 @@ use crate::{ DeploymentConfig, DeploymentState, DeploymentStatus, DeploymentStepResult, ErrorData, Result, }; -use alien_core::{ClientConfig, EnvironmentInfo, Platform, Stack, StackState}; +use alien_core::{ + ClientConfig, EnvironmentInfo, ManagementPermissions, Platform, Stack, StackState, +}; use alien_error::AlienError; use alien_error::Context; use tracing::info; @@ -169,6 +171,17 @@ pub fn strip_declined_resources( for profile in stack.permissions.profiles.values_mut() { profile.0.shift_remove(resource_id.as_str()); } + // The management profile is a parallel resource-scoped grant store: an + // Extend/Override entry for a declined resource must go too, or the + // management role keeps a namespace grant the resource no longer backs. + // Auto needs no strip — the management mutation re-derives it from the + // stripped resource set. + match &mut stack.permissions.management { + ManagementPermissions::Extend(profile) | ManagementPermissions::Override(profile) => { + profile.0.shift_remove(resource_id.as_str()); + } + ManagementPermissions::Auto => {} + } } Ok(stack) @@ -262,8 +275,8 @@ fn environment_collection_context( mod tests { use super::*; use alien_core::{ - Kv, KubernetesClientConfig, PermissionProfile, Resource, ResourceLifecycle, - ResourceStatus, ServiceAccount, StackInputDefinition, StackResourceState, + Kv, KubernetesClientConfig, ManagementPermissions, PermissionProfile, Resource, + ResourceLifecycle, ResourceStatus, ServiceAccount, StackInputDefinition, StackResourceState, }; fn imported_state_with(resource_id: &str, resource: Resource) -> StackState { @@ -397,6 +410,58 @@ mod tests { ); } + /// A declined resource must also lose its resource-scoped grant from an + /// Extend/Override management profile, not only the named profiles — that + /// profile is a second store the management role reads from. + #[test] + fn a_declined_resource_loses_its_management_grant() { + let mut stack = Stack::new("gated-stack".to_string()) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + Kv::new("events".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_enabled_when( + Kv::new("analytics".to_string()).build(), + ResourceLifecycle::Frozen, + "analyticsEnabled", + ) + .build(); + stack.permissions.management = ManagementPermissions::Extend( + PermissionProfile::new() + .resource("analytics", ["kv/management"]) + .resource("events", ["kv/management"]), + ); + + // The import delivered the service account but not the gated `analytics` + // store, so `analytics` is the deployer's declined resource. + let state = imported_state_with( + "execution-sa", + Resource::new(ServiceAccount::new("execution-sa".to_string()).build()), + ); + + let stripped = strip_declined_resources(stack, &state, &Default::default()) + .expect("frozen rules never error"); + + let management = match &stripped.permissions.management { + ManagementPermissions::Extend(profile) | ManagementPermissions::Override(profile) => { + profile + } + ManagementPermissions::Auto => panic!("expected an Extend management profile"), + }; + assert!( + !management.0.contains_key("analytics"), + "the declined resource's management grant is withdrawn" + ); + assert!( + management.0.contains_key("events"), + "a kept resource's management grant survives" + ); + } + /// An empty state means this runner creates the frozen resources itself /// (a direct deploy), so absence carries no answer and nothing is dropped. #[test] From 47e24b0514c4a1b96b9a8effbcd89171627653c0 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:17:47 +0300 Subject: [PATCH 10/12] test(e2e): add enabled-demo real-cloud distribution test for .enabled Adds a distribution test app with a worker plus a matched on/off pair of every gated resource type (KV, storage, queue, vault) behind .enabled(input). The test answers the four *-on inputs true and the four *-off inputs false, provisions to AWS via Terraform, then asserts each enabled resource and its grant reach the imported stack_state and the account while each declined resource is absent. Wires TestApp::EnabledDemo through the harness (input_values -> tfvars) and adds the terraform-aws matrix entry to e2e-cloud.yml behind an enabled-demo app filter. --- .github/workflows/e2e-cloud.yml | 6 + crates/alien-test/src/distribution.rs | 32 +++- crates/alien-test/src/e2e.rs | 10 +- crates/alien-test/tests/distribution.rs | 146 ++++++++++++++++++ tests/e2e/test-apps/enabled-demo/alien.ts | 124 +++++++++++++++ tests/e2e/test-apps/enabled-demo/package.json | 21 +++ tests/e2e/test-apps/enabled-demo/src/index.ts | 20 +++ .../e2e/test-apps/enabled-demo/tsconfig.json | 14 ++ .../test-apps/enabled-demo/tsdown.config.ts | 8 + 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/test-apps/enabled-demo/alien.ts create mode 100644 tests/e2e/test-apps/enabled-demo/package.json create mode 100644 tests/e2e/test-apps/enabled-demo/src/index.ts create mode 100644 tests/e2e/test-apps/enabled-demo/tsconfig.json create mode 100644 tests/e2e/test-apps/enabled-demo/tsdown.config.ts diff --git a/.github/workflows/e2e-cloud.yml b/.github/workflows/e2e-cloud.yml index 939e5716c..2cb5c811d 100644 --- a/.github/workflows/e2e-cloud.yml +++ b/.github/workflows/e2e-cloud.yml @@ -64,6 +64,7 @@ on: - comprehensive-rust - comprehensive-ts - full-stack-microservices + - enabled-demo - command-routing-ts - container-rust - runtime-less-mixed @@ -359,6 +360,11 @@ jobs: TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-aks-helm-pull-comprehensive-rust","test_filter":"terraform_aks_helm_pull_comprehensive_rust","needs_oidc":true,"resource_suffix":"tfakscr"}]') fi fi + if [ "$APP" = "All" ] || [ "$APP" = "enabled-demo" ]; then + if [ "$PUSH_AWS_TERRAFORM" = "true" ]; then + TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-aws-push-enabled-demo","test_filter":"terraform_aws_push_enabled_demo","needs_oidc":false,"resource_suffix":"tfawsed","bindings_arch":"aarch64"}]') + fi + fi if [ "$APP" = "All" ] || [ "$APP" = "full-stack-microservices" ]; then if [ "$KUBERNETES_AWS_TERRAFORM_HELM" = "true" ]; then TERRAFORM_ENTRIES=$(echo "$TERRAFORM_ENTRIES" | jq -c '. + [{"name":"terraform-eks-helm-pull-full-stack-microservices","test_filter":"terraform_eks_helm_pull_full_stack_microservices","needs_oidc":false,"resource_suffix":"tfeksfs","bindings_arch":"aarch64"}]') diff --git a/crates/alien-test/src/distribution.rs b/crates/alien-test/src/distribution.rs index 4ef534daa..8374067d8 100644 --- a/crates/alien-test/src/distribution.rs +++ b/crates/alien-test/src/distribution.rs @@ -86,7 +86,10 @@ impl DistributionArtifactCleanup { } } - fn command_env(&self) -> &[(String, String)] { + /// Target-scoped credentials/region the artifact was applied with. Exposed + /// so distribution tests can make read-only cloud assertions against the + /// same account the setup artifact provisioned into. + pub fn command_env(&self) -> &[(String, String)] { match self { DistributionArtifactCleanup::CloudFormation { env, .. } | DistributionArtifactCleanup::Terraform { env, .. } @@ -3207,6 +3210,26 @@ fn terraform_output_u32(outputs: &Value, key: &str) -> anyhow::Result { anyhow::bail!("terraform output {key} is not a number or string") } +/// The gate answers the enabled-demo e2e applies: the four `*On` inputs true, +/// the four `*Off` inputs false. Tuple keys are the Terraform variable names the +/// generator emits for each input id (`input_` + snake_case), so a change to +/// `stack_input_variable_name` must be mirrored here. Empty for every other app. +fn enabled_demo_gate_answers(app: TestApp) -> &'static [(&'static str, bool)] { + match app { + TestApp::EnabledDemo => &[ + ("input_kv_on", true), + ("input_kv_off", false), + ("input_storage_on", true), + ("input_storage_off", false), + ("input_queue_on", true), + ("input_queue_off", false), + ("input_vault_on", true), + ("input_vault_off", false), + ], + _ => &[], + } +} + fn terraform_tfvars( prepared: &DistributionPrepared, target: alien_terraform::TerraformTarget, @@ -3230,6 +3253,13 @@ fn terraform_tfvars( Value::String(prepared.manager.url.clone()), ); + // Answer deployer gate inputs at apply time. Terraform auto-loads + // terraform.tfvars.json, so a `input_` key here is how the + // harness threads a `.enabled(input)` answer into the applied artifact. + for (tfvar, answer) in enabled_demo_gate_answers(prepared.app) { + vars.insert(tfvar.to_string(), Value::Bool(*answer)); + } + match target.cloud_platform() { Platform::Aws => { let target = prepared diff --git a/crates/alien-test/src/e2e.rs b/crates/alien-test/src/e2e.rs index af7ae7de5..e0b911c0b 100644 --- a/crates/alien-test/src/e2e.rs +++ b/crates/alien-test/src/e2e.rs @@ -148,6 +148,11 @@ pub enum TestApp { /// TypeScript SOURCE Container + Rust SOURCE Daemon sharing a direct KV /// binding and registering the same target-scoped command. RuntimeLessMixed, + /// Worker plus a matched on/off pair of every gated resource type (KV, + /// storage, queue, vault) behind `.enabled(input)`, for verifying that a + /// declined resource and its grant never reach the cloud + /// (`tests/e2e/test-apps/enabled-demo`). + EnabledDemo, } impl std::fmt::Display for TestApp { @@ -159,6 +164,7 @@ impl std::fmt::Display for TestApp { TestApp::CommandRoutingTs => write!(f, "command-routing-ts"), TestApp::ContainerRust => write!(f, "container-rust"), TestApp::RuntimeLessMixed => write!(f, "runtime-less-mixed"), + TestApp::EnabledDemo => write!(f, "enabled-demo"), } } } @@ -363,6 +369,7 @@ pub(crate) fn test_app_path(app: TestApp) -> &'static str { TestApp::CommandRoutingTs => "../../examples/command-routing-ts", TestApp::ContainerRust => "test-apps/container-rust", TestApp::RuntimeLessMixed => "test-apps/runtime-less-mixed", + TestApp::EnabledDemo => "test-apps/enabled-demo", } } @@ -379,7 +386,8 @@ fn deployment_environment_variables( | TestApp::ComprehensiveTs | TestApp::CommandRoutingTs | TestApp::ContainerRust - | TestApp::RuntimeLessMixed => None, + | TestApp::RuntimeLessMixed + | TestApp::EnabledDemo => None, TestApp::FullStackMicroservices => { Some(vec![alien_manager_api::types::EnvironmentVariable { name: "APP_SECRET".to_string(), diff --git a/crates/alien-test/tests/distribution.rs b/crates/alien-test/tests/distribution.rs index 95c085d1b..15964039a 100644 --- a/crates/alien-test/tests/distribution.rs +++ b/crates/alien-test/tests/distribution.rs @@ -60,9 +60,143 @@ async fn check_distribution_deployment(ctx: &mut alien_test::TestContext) { panic!("mixed runtime-less checks failed: {error:#}"); } } + TestApp::EnabledDemo => { + if let Err(error) = check_enabled_demo(ctx).await { + panic!("enabled-demo gate checks failed: {error:#}"); + } + } } } +/// Verifies the `.enabled(input)` gate end to end on a real cloud: after setup +/// applied the Terraform artifact with four `*On` inputs answered true and four +/// `*Off` answered false, every gated-on resource (and the ungated control) +/// must be created and every gated-off resource must be absent — proving the +/// `count = 0` path applies cleanly and a declined resource never reaches the +/// cloud. +async fn check_enabled_demo(ctx: &mut alien_test::TestContext) -> anyhow::Result<()> { + use anyhow::Context as _; + + // Manager-level outcome: the imported stack_state reflects exactly what the + // gated Terraform apply produced. Gated-off resources are absent from the + // registration payload, so they never enter stack_state. + let resp = ctx + .deployment + .manager() + .client() + .get_deployment() + .id(&ctx.deployment.id) + .send() + .await + .map_err(|error| anyhow::anyhow!("get_deployment failed: {error}"))?; + let state_value = resp + .into_inner() + .stack_state + .context("deployment is missing stack_state")?; + let stack_state: alien_core::StackState = + serde_json::from_value(state_value).context("failed to parse stack_state")?; + let present: std::collections::HashSet = + stack_state.resources.keys().cloned().collect(); + + for id in [ + "state", + "optional-kv-on", + "optional-storage-on", + "optional-queue-on", + "optional-vault-on", + ] { + anyhow::ensure!( + present.contains(id), + "expected gated-on/control resource '{id}' present in stack_state, got {present:?}" + ); + } + for id in [ + "optional-kv-off", + "optional-storage-off", + "optional-queue-off", + "optional-vault-off", + ] { + anyhow::ensure!( + !present.contains(id), + "declined resource '{id}' must be absent from stack_state, got {present:?}" + ); + } + + // Cloud-level control: the resource id is embedded in every cloud resource + // name, so a substring scan over the target account is naming-agnostic and + // proves the count=0 apply left nothing behind. Uses the Terraform cleanup's + // target credentials/region. + let env = ctx + .distribution_cleanups + .iter() + .map(|cleanup| cleanup.command_env().to_vec()) + .find(|env| !env.is_empty()) + .context("no distribution cleanup env for cloud assertions")?; + + assert_cloud_gate_pair( + &env, + &["dynamodb", "list-tables", "--output", "json"], + "optional-kv-on", + "optional-kv-off", + ) + .await?; + assert_cloud_gate_pair( + &env, + &["s3api", "list-buckets", "--output", "json"], + "optional-storage-on", + "optional-storage-off", + ) + .await?; + assert_cloud_gate_pair( + &env, + &["sqs", "list-queues", "--output", "json"], + "optional-queue-on", + "optional-queue-off", + ) + .await?; + + Ok(()) +} + +/// Runs one read-only `aws` list call and asserts the enabled sibling's id is +/// present in the output while the declined sibling's id is absent. +async fn assert_cloud_gate_pair( + env: &[(String, String)], + aws_args: &[&str], + on_id: &str, + off_id: &str, +) -> anyhow::Result<()> { + use anyhow::Context as _; + + let mut cmd = tokio::process::Command::new("aws"); + cmd.args(aws_args); + for (key, value) in env { + cmd.env(key, value); + } + let output = cmd + .output() + .await + .with_context(|| format!("failed to run aws {}", aws_args.join(" ")))?; + anyhow::ensure!( + output.status.success(), + "aws {} failed: {}", + aws_args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + stdout.contains(on_id), + "enabled resource '{on_id}' not found in target account (aws {})", + aws_args.join(" ") + ); + anyhow::ensure!( + !stdout.contains(off_id), + "declined resource '{off_id}' must not exist in target account (aws {})", + aws_args.join(" ") + ); + Ok(()) +} + async fn public_url(ctx: &mut alien_test::TestContext) -> anyhow::Result { ctx.deployment .wait_for_public_url(Duration::from_secs(180)) @@ -690,6 +824,18 @@ async fn terraform_aws_push_comprehensive_rust(ctx: &mut TerraformAwsPushRust) { check_distribution_deployment(&mut ctx.ctx).await; } +distribution_test_context!( + TerraformAwsPushEnabledDemo, + DistributionFlow::TerraformAwsPush, + TestApp::EnabledDemo +); + +#[test_context(TerraformAwsPushEnabledDemo)] +#[tokio::test] +async fn terraform_aws_push_enabled_demo(ctx: &mut TerraformAwsPushEnabledDemo) { + check_distribution_deployment(&mut ctx.ctx).await; +} + distribution_test_context!( TerraformGcpPushRust, DistributionFlow::TerraformGcpPush, diff --git a/tests/e2e/test-apps/enabled-demo/alien.ts b/tests/e2e/test-apps/enabled-demo/alien.ts new file mode 100644 index 000000000..1ad13ccbe --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/alien.ts @@ -0,0 +1,124 @@ +import * as alien from "@alienplatform/core" + +// A deployer-input gate per resource type, in matched on/off pairs. The e2e +// answers the four `*On` inputs true and the four `*Off` inputs false at apply +// time, then verifies each on-resource (and its grant) exists in the cloud +// while each off-resource is absent. Defaults are false so the on-resources +// only appear when the harness actually threads the answer through — otherwise +// the test would pass without proving the gate value was applied. +const io = alien.inputs({ + kvOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on key-value store", + description: "Answered true by the e2e; the store must exist.", + }), + kvOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off key-value store", + description: "Answered false by the e2e; the store must be absent.", + }), + storageOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on object store", + description: "Answered true by the e2e; the bucket must exist.", + }), + storageOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off object store", + description: "Answered false by the e2e; the bucket must be absent.", + }), + queueOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on queue", + description: "Answered true by the e2e; the queue must exist.", + }), + queueOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off queue", + description: "Answered false by the e2e; the queue must be absent.", + }), + vaultOn: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the on secret store", + description: "Answered true by the e2e; its grant must exist.", + }), + vaultOff: alien.boolean({ + providedBy: "deployer", + required: false, + default: false, + label: "Enable the off secret store", + description: "Answered false by the e2e; its grant must be absent.", + }), +}) + +// Ungated positive control: proves setup actually provisioned resources, so an +// absent off-resource is a real gate outcome rather than an empty deployment. +// Frozen (setup-created) so the worker's read grant bakes into the execution +// role at setup. A Live data resource would defer that grant to a runtime +// PutRolePolicy on the setup-owned role, which the management role is not +// permitted to do — orthogonal to the gate this app exists to exercise. +const state = new alien.Kv("state").build() + +const kvOn = new alien.Kv("optional-kv-on").enabled(io.kvOn).build() +const kvOff = new alien.Kv("optional-kv-off").enabled(io.kvOff).build() +const storageOn = new alien.Storage("optional-storage-on").enabled(io.storageOn).build() +const storageOff = new alien.Storage("optional-storage-off").enabled(io.storageOff).build() +const queueOn = new alien.Queue("optional-queue-on").enabled(io.queueOn).build() +const queueOff = new alien.Queue("optional-queue-off").enabled(io.queueOff).build() +const vaultOn = new alien.Vault("optional-vault-on").enabled(io.vaultOn).build() +const vaultOff = new alien.Vault("optional-vault-off").enabled(io.vaultOff).build() + +const agent = new alien.Worker("agent") + .code({ type: "source", src: "./", toolchain: { type: "typescript" } }) + .commandsEnabled(true) + .publicEndpoint("api") + .permissions("execution") + .build() + +export default new alien.Stack("enabled-demo") + .inputs(io) + .add(state, "frozen") + .add(kvOn, "frozen") + .add(kvOff, "frozen") + .add(storageOn, "frozen") + .add(storageOff, "frozen") + .add(queueOn, "frozen") + .add(queueOff, "frozen") + .add(vaultOn, "frozen") + .add(vaultOff, "frozen") + .add(agent, "live") + .permissions({ + profiles: { + // Each gated resource carries its own resource-scoped grant so the e2e can + // assert the grant follows the gate (present when on, gone when off). The + // worker binds only the ungated `state` store; it depends on no gated + // resource, so the ungated-dependent-of-a-gated-resource preflight stays + // satisfied. + execution: { + state: ["kv/data-read"], + "optional-kv-on": ["kv/data-read"], + "optional-kv-off": ["kv/data-read"], + "optional-storage-on": ["storage/data-read"], + "optional-storage-off": ["storage/data-read"], + "optional-queue-on": ["queue/data-read"], + "optional-queue-off": ["queue/data-read"], + "optional-vault-on": ["vault/data-read"], + "optional-vault-off": ["vault/data-read"], + }, + }, + }) + .build() diff --git a/tests/e2e/test-apps/enabled-demo/package.json b/tests/e2e/test-apps/enabled-demo/package.json new file mode 100644 index 000000000..9a9a297d8 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/package.json @@ -0,0 +1,21 @@ +{ + "name": "enabled-demo", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "tsdown --watch --no-clean", + "build": "tsdown", + "test:ts": "tsc --noEmit" + }, + "dependencies": { + "@alienplatform/sdk": "workspace:*", + "@alienplatform/core": "workspace:*", + "hono": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "tsdown": "^0.13.0", + "typescript": "^5.7.3" + } +} diff --git a/tests/e2e/test-apps/enabled-demo/src/index.ts b/tests/e2e/test-apps/enabled-demo/src/index.ts new file mode 100644 index 000000000..778ac8334 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/src/index.ts @@ -0,0 +1,20 @@ +import { command } from "@alienplatform/sdk" +import { Hono } from "hono" + +const app = new Hono() + +app.get("/health", c => { + return c.json({ + status: "ok", + timestamp: new Date().toISOString(), + }) +}) + +command("echo", async params => { + return { + ...params, + timestamp: new Date().toISOString(), + } +}) + +export default app diff --git a/tests/e2e/test-apps/enabled-demo/tsconfig.json b/tests/e2e/test-apps/enabled-demo/tsconfig.json new file mode 100644 index 000000000..5d7d34a68 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/e2e/test-apps/enabled-demo/tsdown.config.ts b/tests/e2e/test-apps/enabled-demo/tsdown.config.ts new file mode 100644 index 000000000..63c84ccc5 --- /dev/null +++ b/tests/e2e/test-apps/enabled-demo/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + clean: true, + dts: false, +}) From d45a7a70a9429730c9587150a67d20aa442cd74a Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:26:14 +0300 Subject: [PATCH 11/12] chore: register enabled-demo in the pnpm lockfile --- pnpm-lock.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 629e5ea69..2089fb2bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,28 @@ importers: specifier: ^5.8.3 version: 5.8.3 + tests/e2e/test-apps/enabled-demo: + dependencies: + '@alienplatform/core': + specifier: link:../../../../packages/core + version: link:../../../../packages/core + '@alienplatform/sdk': + specifier: link:../../../../packages/sdk + version: link:../../../../packages/sdk + hono: + specifier: ^4.0.0 + version: 4.11.3 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.13 + tsdown: + specifier: ^0.13.0 + version: 0.13.5(typescript@5.8.3) + typescript: + specifier: ^5.7.3 + version: 5.8.3 + tests/e2e/test-apps/runtime-less-mixed: dependencies: '@alienplatform/bindings': From bec4bb24969a7e43be3c888712ade2b9c0304b05 Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 23 Jul 2026 17:38:47 +0300 Subject: [PATCH 12/12] fix(e2e): avoid spreading untyped command params in enabled-demo --- tests/e2e/test-apps/enabled-demo/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test-apps/enabled-demo/src/index.ts b/tests/e2e/test-apps/enabled-demo/src/index.ts index 778ac8334..88ca533d1 100644 --- a/tests/e2e/test-apps/enabled-demo/src/index.ts +++ b/tests/e2e/test-apps/enabled-demo/src/index.ts @@ -12,7 +12,7 @@ app.get("/health", c => { command("echo", async params => { return { - ...params, + params, timestamp: new Date().toISOString(), } })