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-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..5bc69795a --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/enabled_vault_queue_tests.rs @@ -0,0 +1,496 @@ +//! `.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"; + +/// 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."), + 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(VAULT_ID, ["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(VAULT_ID.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(), + "JobsExecutionSaRoleQueuePermission00".to_string(), + ], + "the queue owns the SQS queue and its resource-scoped grant, both 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(VAULT_ID)) + .unwrap_or(false)), + "no vault policy may survive the gate being off" + ); +} + +/// 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(), + VAULT_ID.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(), + VAULT_ID.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(), + VAULT_ID.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 + +/// 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..4dd16e992 --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__enabled_vault_queue_tests__enabled_gated_queue_and_vault.snap @@ -0,0 +1,303 @@ +--- +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 + 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: + PolicyName: + Fn::Sub: ${AWS::StackName}-app-tokens-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}-app-tokens-* + 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: app-tokens + type: vault + importData: + accountId: + Ref: AWS::AccountId + region: + Ref: AWS::Region + parameterPrefix: + Fn::Sub: ${AWS::StackName}-app-tokens + - Ref: AWS::NoValue + - ']' + Description: Deployment registration resources JSON. diff --git a/crates/alien-deployment/src/pending.rs b/crates/alien-deployment/src/pending.rs index c663923a2..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; @@ -156,6 +158,30 @@ 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. 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()); + } + // 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) @@ -249,8 +275,8 @@ fn environment_collection_context( mod tests { use super::*; use alien_core::{ - Kv, KubernetesClientConfig, 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 { @@ -313,6 +339,129 @@ mod tests { assert!(stripped.resources.contains_key("execution-sa")); } + /// 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()) + .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", + ) + .permission( + "execution", + PermissionProfile::new() + .resource("analytics", ["kv/write"]) + .resource("events", ["kv/read"]) + .resource("*", ["worker/invoke"]), + ) + .build(); + + // 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"); + + 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("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" + ); + } + + /// 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] 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..f22703fb6 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; @@ -93,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 @@ -120,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() + )); + } } } @@ -180,6 +217,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 +273,70 @@ 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; + } + + // 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}'. {remedy}", + sibling.config.resource_type() + )); + } + } + errors +} + #[cfg(test)] mod tests { use super::*; @@ -467,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. @@ -564,6 +691,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()) diff --git a/crates/alien-terraform/src/emitters/aws/vault.rs b/crates/alien-terraform/src/emitters/aws/vault.rs index 0129cdcae..6f37fff5c 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,16 @@ impl TfEmitter for AwsVaultEmitter { ])) } + /// 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. + 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..0e4611eeb 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,17 @@ impl TfEmitter for GcpVaultEmitter { ])) } + /// 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 + } + 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..37fc458fe --- /dev/null +++ b/crates/alien-terraform/tests/generator/enabled_vault_queue_tests.rs @@ -0,0 +1,650 @@ +//! `.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}" + ); +} + +/// 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 [ + (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); +} + +/// 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. 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/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': 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..88ca533d1 --- /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, +})