diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index eb6566a3e..9989bf12f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -143,10 +143,10 @@ jobs: - name: Run host-target OpenRTB codegen clippy run: cargo clippy --package trusted-server-openrtb-codegen --target x86_64-unknown-linux-gnu --all-targets -- -D warnings - # The build-digest tests are native-only, so clippy-fastly compiles them - # to an empty crate. Lint them on the host. - - name: Run host-target core build-digest test clippy - run: cargo clippy --package trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest -- -D warnings + # These core tests are native-only, so clippy-fastly compiles them + # to empty crates. Lint them on the host. + - name: Run host-target core build-digest and AWS config test clippy + run: cargo clippy --package trusted-server-core --target x86_64-unknown-linux-gnu --test template_build_digest --test aws_example_config -- -D warnings format-typescript: runs-on: ubuntu-latest diff --git a/.github/workflows/native-aws-workload.yml b/.github/workflows/native-aws-workload.yml new file mode 100644 index 000000000..8c19b12ca --- /dev/null +++ b/.github/workflows/native-aws-workload.yml @@ -0,0 +1,62 @@ +name: Native AWS deployment example + +on: + pull_request: + paths: + - 'deploy/examples/native-aws-workload/**' + - '.tool-versions' + - '.github/workflows/native-aws-workload.yml' + push: + branches: [main] + paths: + - 'deploy/examples/native-aws-workload/**' + - '.tool-versions' + - '.github/workflows/native-aws-workload.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline-checks: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + AWS_EC2_METADATA_DISABLED: 'true' + TF_IN_AUTOMATION: 'true' + CHECKPOINT_DISABLE: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Read pinned tooling + id: versions + run: | + terraform=$(awk '$1 == "terraform" { print $2 }' .tool-versions) + node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions) + test -n "$terraform" + test -n "$node" + printf 'terraform=%s\nnode=%s\n' "$terraform" "$node" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ steps.versions.outputs.node }} + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: ${{ steps.versions.outputs.terraform }} + terraform_wrapper: false + + - name: Check the offline workflow contract + run: node --test deploy/examples/native-aws-workload/test-workflow.mjs + + - name: Validate schema and credential-free mock plans + run: | + terraform -chdir=deploy/examples/native-aws-workload fmt -check -recursive + terraform -chdir=deploy/examples/native-aws-workload init -backend=false -input=false -lockfile=readonly + terraform -chdir=deploy/examples/native-aws-workload validate + terraform -chdir=deploy/examples/native-aws-workload/retained init -backend=false -input=false -lockfile=readonly + terraform -chdir=deploy/examples/native-aws-workload/retained validate + env -u NODE_OPTIONS -u NODE_PATH node deploy/examples/native-aws-workload/test-draft.mjs + git diff --exit-code -- deploy/examples/native-aws-workload/.terraform.lock.hcl deploy/examples/native-aws-workload/retained/.terraform.lock.hcl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81599ac13..0e617dab1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -186,8 +186,8 @@ jobs: path: ${{ runner.temp }}/trusted-server-finalize-axum-*/ if-no-files-found: ignore - - name: Test template build digest inputs (native) - run: cargo test -p trusted-server-core --test template_build_digest + - name: Test build digest inputs and AWS config envelopes (native) + run: cargo test -p trusted-server-core --test template_build_digest --test aws_example_config - name: Run HTML processor benchmarks (smoke) # -- --test runs each benchmark as a regular test (no timing harness) so CI stays fast diff --git a/crates/trusted-server-core/tests/aws_example_config.rs b/crates/trusted-server-core/tests/aws_example_config.rs new file mode 100644 index 000000000..c28df4537 --- /dev/null +++ b/crates/trusted-server-core/tests/aws_example_config.rs @@ -0,0 +1,159 @@ +//! Verify the committed AWS examples through the native runtime config loader. + +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::BTreeSet; + +use error_stack::Report; +use trusted_server_core::config_payload::VerifiedConfigBlob; +use trusted_server_core::ec::storage::{EcRuntime, EcStorageSelection}; +use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; + +const ENVELOPES: [(&str, &str); 2] = [ + ( + "default", + include_str!("../../../deploy/examples/native-aws-workload/example-envelope.json"), + ), + ( + "noindex", + include_str!("../../../deploy/examples/native-aws-workload/example-noindex-envelope.json"), + ), +]; + +struct FictionalSecrets; + +#[async_trait::async_trait(?Send)] +impl PlatformSecretStore for FictionalSecrets { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + if store_name.as_ref() != "trusted_server_secrets" { + return Err(Report::new(PlatformError::SecretStore)); + } + let value = match key { + "handler_password" => "fictional-reviewer-password", + "publisher_proxy_secret" => "fictional-publisher-proxy-secret-32-bytes", + "ec_passphrase" => "fictional-ec-passphrase-at-least-32-bytes", + _ => return Err(Report::new(PlatformError::SecretStore)), + }; + Ok(value.as_bytes().to_vec()) + } + + async fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore)) + } + + async fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore)) + } +} + +#[test] +fn committed_aws_envelopes_pass_runtime_integrity_and_settings_validation() { + let expected_keys = BTreeSet::from([ + "handler_password".to_owned(), + "publisher_proxy_secret".to_owned(), + "ec_passphrase".to_owned(), + ]); + for (name, envelope) in ENVELOPES { + let blob = VerifiedConfigBlob::parse(envelope) + .unwrap_or_else(|error| panic!("should verify {name} envelope: {error:?}")); + assert_eq!( + blob.required_secret_keys() + .expect("should collect reference-only secret keys"), + expected_keys, + "should resolve only the three declared application secrets for {name}" + ); + let settings = blob + .into_settings( + &FictionalSecrets, + &StoreName::from("trusted_server_secrets"), + ) + .unwrap_or_else(|error| panic!("should validate {name} settings: {error:?}")); + assert_eq!( + settings.publisher.domain, "publisher.example.com", + "should preserve the operator-configurable publisher fixture" + ); + assert_eq!( + settings.publisher.proxy_secret.expose(), + "fictional-publisher-proxy-secret-32-bytes", + "should resolve the proxy reference without using a real provider" + ); + let Some(EcStorageSelection::DynamoDb(storage)) = settings + .ec + .resolve_storage(EcRuntime::Native) + .expect("should resolve native EC storage") + else { + panic!("should select DynamoDB for the {name} AWS envelope"); + }; + assert_eq!( + storage.region, "eu-west-2", + "should use the retained region" + ); + assert_eq!( + storage.table, "example-retained-ec", + "should select the retained table" + ); + assert_eq!( + storage.namespace, "example-staging", + "should preserve the stable retained namespace" + ); + assert!( + storage.local_endpoint.is_none(), + "should not publish a local emulator endpoint" + ); + assert_eq!( + settings + .response_headers + .get("X-Robots-Tag") + .map(String::as_str), + (name == "noindex").then_some("noindex"), + "should preserve the optional noindex configuration" + ); + } +} + +#[test] +fn rewriting_cli_float_tokens_invalidates_the_aws_envelopes() { + for (name, envelope) in ENVELOPES { + let rewritten = + envelope.replacen("\"access_sample_rate\":0.0", "\"access_sample_rate\":0", 1); + assert_ne!( + rewritten, envelope, + "should exercise the original float-to-integer regression for {name}" + ); + let error = VerifiedConfigBlob::parse(&rewritten) + .err() + .expect("should reject numeric normalization after CLI publication"); + assert!( + error.to_string().contains("integrity verification"), + "should detect rewritten {name} config before startup: {error:?}" + ); + } +} + +#[test] +fn tampered_aws_envelopes_fail_runtime_integrity_verification() { + for (name, envelope) in ENVELOPES { + let mut tampered: serde_json::Value = + serde_json::from_str(envelope).expect("should parse the committed envelope"); + tampered["data"]["publisher"]["domain"] = + serde_json::json!("different.publisher.example.com"); + let tampered = + serde_json::to_string(&tampered).expect("should serialize the tampered envelope"); + let error = VerifiedConfigBlob::parse(&tampered) + .err() + .expect("should reject a data change without a matching checksum"); + assert!( + error.to_string().contains("integrity verification"), + "should reject tampered {name} through the runtime integrity check: {error:?}" + ); + } +} diff --git a/deploy/examples/native-aws-workload/.gitignore b/deploy/examples/native-aws-workload/.gitignore new file mode 100644 index 000000000..eef2b17fc --- /dev/null +++ b/deploy/examples/native-aws-workload/.gitignore @@ -0,0 +1,12 @@ +.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +*.auto.tfvars +*.auto.tfvars.json +*.backend.hcl +crash.log +crash.*.log +.terraform.tfstate.lock.info diff --git a/deploy/examples/native-aws-workload/.terraform.lock.hcl b/deploy/examples/native-aws-workload/.terraform.lock.hcl new file mode 100644 index 000000000..4f85daae2 --- /dev/null +++ b/deploy/examples/native-aws-workload/.terraform.lock.hcl @@ -0,0 +1,29 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.64.0" + constraints = "6.64.0" + hashes = [ + "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", + "h1:4siTahLyzGh4BoMQcL7VXeL/mn8iR/zKv93NyhQob+0=", + "h1:EEWCXlg69fty/Qi+kehrREnVEaWdKnLlVon542b6nxQ=", + "h1:wXARLY+IeQ7ufYxCLTPCwToWGMRvOpiOTfJS97iwUzI=", + "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", + "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", + "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", + "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", + "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", + "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", + "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", + "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", + "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", + "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", + "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", + "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", + "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", + "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", + ] +} diff --git a/deploy/examples/native-aws-workload/README.md b/deploy/examples/native-aws-workload/README.md new file mode 100644 index 000000000..dc995166c --- /dev/null +++ b/deploy/examples/native-aws-workload/README.md @@ -0,0 +1,185 @@ +# Native AWS workload example + +One example combines retained DynamoDB EC storage with an Axum workload on Fargate, AppConfig, Secrets Manager, an ALB and protected CloudFront Geo. The main fictional tfvars select CloudFront at `dns-cutover`; explicit `geo_front_door = null` selects Geo Off. The retained table and disposable workload have separate Terraform roots and separate locked state keys. + +This is a closed-gate example, not an approved deployment recipe. It does not qualify an AWS account, final application image or public ingress. Operational deployment belongs in the separate, access-controlled deployer repository. Access to that repository is not required to understand this example or run its offline checks. No AWS deployment or cleanup is authorized here. + +```mermaid +flowchart TD + Retained[Retained Terraform root] --> Table[Regional DynamoDB EC table] + Retained --> Binding[Non-secret ec_storage handoff] + Binding --> TOML[Operator-owned app-config TOML] + TOML --> CLI[Matching TS CLI produces exact BlobEnvelope] + CLI --> Workload[Workload Terraform root] + Binding --> Workload + Workload --> AppConfig[AppConfig envelope document] + Browser[HTTPS browser] --> CloudFront[Protected CloudFront, no shared cache] + CloudFront --> ALB[HTTPS ALB with exact origin admission] + ALB --> App[Nonroot Axum task] + App --> Table + App --> Origin[Configured publisher origin] + App --> Agent[Authenticated loopback AppConfig Agent] + Agent --> AppConfig + App --> Secrets[Referenced Secrets Manager versions] +``` + +Without CloudFront, explicit reviewer CIDRs govern ALB ingress. The application port remains ALB-only in either mode. A task public IP supplies outbound connectivity; this is not private-subnet Fargate. + +## Roots and input ownership + +| Path | Ownership and lifetime | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `retained/` | Retained owner creates the table and exports `ec_storage`. No task role, policy attachment or workload-state dependency. | +| This directory | Workload owner creates the task role, attaches its scoped DynamoDB policy, and configures ECS, ingress and AppConfig. Workload removal must leave the retained table intact. | + +Both roots use S3 lockfile locking, but neither supplies a backend key. The operator must explicitly supply stable installation/workload-specific keys, the separately owned bucket and backend region through the approved operator path. For illustration only, one installation could use `example-staging/retained.tfstate` and `example-staging/workload.tfstate`. Retained and workload keys must differ. Never reuse another environment's keys or point both roots at one key. Keep state encrypted, versioned, access-controlled and locked. + +| Input | Responsible owner | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| AWS account, region, state bucket/access and operator authority | Account and state owners, outside these roots | +| Retained `owner`, table name and namespace reservation | Retained owner, including allocator continuity, data lifetime, costs and eventual retirement | +| `ec_storage` | Retained owner hands off the exact provider, region, table and namespace; application and workload owners use the same object | +| Publisher origin, hostname/cookie arrangement, authentication and consent signal transport | Application/publisher operator, reviewed before browser acceptance | +| Digest-pinned application/runtime and Agent images | Image owner, with final-image evidence rather than placeholder digest syntax | +| Secret ARN/version references and Agent token version | Secret owner seeds values separately; no values enter app config or application tfvars | +| Task/execution permission boundaries | Boundary owner reviews effective permissions; the workload's `ec_task_policy_json` is a scoped allow document, not a replacement boundary | +| Certificates, hosted zone, reviewer access, CloudFront credentials and routing phase | Ingress owner, with separate live TLS/DNS/admission qualification | + +The example creates no operator role, protected state bucket, permission boundary, certificate, hosted zone or image build/publisher. Fictional tfvars, reserved documentation client CIDRs and placeholder image digests are not deployable inputs. The Agent image placeholder is not a verified official image. Never publish credentials, actual tfvars, state, saved plans, licensed data or private qualification records. + +All workload AWS resources depend directly on `terraform_data.draft_gate`. Its source-controlled blockers cover scoped operator/locked-state authority, final-image/nonroot-volume behavior and live ALB forwarding/auth/health. No input declares those qualifications passed. Mock tests remove blockers only in disposable copies. Offline success is not permission to remove the gate. The retained root's separate lifecycle does not authorize deploying the workload. + +## Ordered configuration handoff + +These steps describe input preparation and ownership. Cloud provisioning and state reads require a separately authorized operator workflow; the commands here are local only. + +### 1. Agree the retained reservation + +The retained owner reviews `retained/terraform.tfvars.example`, account/region, retained state identity and the app/environment reservation. The retained root creates a single-region `PAY_PER_REQUEST` table with string keys `pk` and `sk`, TTL on `ttl_epoch_s`, deletion protection and Terraform `prevent_destroy`. It uses DynamoDB's default encryption at rest with an AWS-owned key. It adds no task KMS permission, indexes or replicas. + +Reserve both `ec:{namespace}` for identities/withdrawal markers and `ec-allocator:{namespace}` for the durable generation allocator. The allocator row has `sk=generation` and no TTL. Never reset it while the namespace may be reused. TTL collects expired rows eventually; runtime `expires_at_ms` controls logical expiry. See [the persistence guide](../../../docs/guide/native-ec-persistence.md) for continuity and retirement requirements. + +### 2. Receive the retained handoff + +After separately approved retained provisioning, the owner supplies its non-secret `ec_storage` output. No task role needs to exist first. The committed fixtures agree on this exact object: + +```hcl +ec_storage = { + provider = "dynamodb" + region = "eu-west-2" + table = "example-retained-ec" + namespace = "example-staging" +} +``` + +The handoff is explicit, not a Terraform remote-state dependency or a task environment binding. Preserve exact values in the workload input and published config. A provider/table/namespace change does not migrate existing identities or withdrawal obligations. + +### 3. Prepare operator-owned TOML + +Start with `example-config.toml` and review the entire application policy. Keep secret references, not resolved values. Include the retained selection: + +```toml +[ec.storage] +provider = "dynamodb" +region = "eu-west-2" +table = "example-retained-ec" +namespace = "example-staging" +``` + +The matching CLI validates application semantics. Terraform does not duplicate TS authentication, indexing, signing, origin-protocol or secret-consumer policy. The fixture's hostname and empty integration settings are not browser acceptance evidence; resolve the operator-owned requirements below without weakening runtime consent checks. + +### 4. Export the exact matching CLI envelope + +Use the TS CLI from the final application's matching source revision. Prepare a disposable checkout of that revision, including `edgezero.toml` and all referenced adapter manifests. Copying only the root manifest is insufficient because CLI validation also reads referenced manifests, including Spin's. Run from the checkout root to validate and publish reference-only TOML with environment overlays disabled: + +```sh +ts config validate --manifest edgezero.toml --app-config /private/site.toml --no-env +ts config push --adapter axum --manifest edgezero.toml --app-config /private/site.toml --local --no-env --no-diff --yes +``` + +Local publication writes `.edgezero/local-config-trusted_server_config.json`, an outer key-to-string map. Extract only the `trusted_server_config` string, preserving its bytes: + +```sh +node --input-type=module -e ' +import { readFileSync, writeFileSync } from "node:fs"; +const map = JSON.parse(readFileSync(".edgezero/local-config-trusted_server_config.json", "utf8")); +writeFileSync("/private/site-envelope.json", map.trusted_server_config, { mode: 0o600 }); +' +``` + +AppConfig consumes that inner `BlobEnvelope` document, not the outer local map or raw TOML. Do not parse and reserialize the envelope, patch its config, or recompute its checksum with Terraform. Regenerate through the matching CLI after any config change. Committed envelopes are offline fixtures, not release exports. + +### 5. Prepare the workload inputs + +The workload owner supplies the same `ec_storage` object and sets `config_envelope_file` to the exact CLI export. The workload checks envelope shape, publisher binding and exact `ec.storage` agreement, then publishes the original envelope text unchanged to AppConfig. It attaches exact-table/partition-scoped DynamoDB permissions to its own application task role. The retained root never attaches a policy to that role. + +Review `ec_task_policy_json` with the boundary owner. An existing task boundary with an explicit deny excluding DynamoDB still denies the calls despite an inline allow. Review its action ceiling and constrained allows, plus organization and endpoint policy denies. Do not remove a boundary, grant administrator access or move application calls to the execution role. The execution role retains image-pull, logs and startup-secret responsibilities. + +The main `terraform.tfvars.example` selects protected CloudFront at `dns-cutover`. The `staged` and `origin-enabled` phases are also modeled; they are not an automated safe rollout or rollback procedure. An explicit `geo_front_door = null` selects the alternate Geo Off path. Neither setting bypasses qualification. + +## Runtime image contract + +Use the TS-owned runtime packaging in [`../../runtime/native-aws/`](../../runtime/native-aws/), not a local development image. Pin the final application image by digest. Its required Axum features are `aws-appconfig-agent,aws-secrets-manager,geo-cloudfront,aws-dynamodb-ec`. Feature names and a valid digest are not evidence of runtime qualification. + +The task expects: + +- A Linux x86_64 application running as UID/GID `10001:10001`, listening on `0.0.0.0:8787`, with a read-only root filesystem and all Linux capabilities dropped. +- A writable, correctly owned `/run/trusted-server` task volume. The runtime entrypoint publishes literal `TS_NATIVE_BINDINGS_TOML` privately and passes `/run/trusted-server/native-bindings.toml` through `EDGEZERO__STORE_BINDINGS_FILE`. The ephemeral volume does not store the identity graph. +- Optional `TS_NATIVE_GEO_PROFILE_TOML` containing the CloudFront provider, ALB peer CIDRs and credential-removal header name, never the expected credential value. The runtime exports `TS_NATIVE_GEO_PROFILE_FILE`. An absent profile means Geo Off; an invalid explicit profile must fail before readiness. No KV Geo database/provider is configured here. +- `EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS` and `EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY=x-forwarded` for checked forwarding. Geo admission and ordinary forwarded-header trust are separate checks. +- An authenticated official AppConfig Agent on loopback `127.0.0.1:2772`, sharing the selected token version with the app. ECS `START` dependency orders startup but does not prove readiness. Qualify bounded cold Agent startup with the selected image/provider. No Agent disk-backup fallback is configured. +- Successful preparation of the selected regional EC table using task-role credentials before readiness. No local-memory fallback or deployed `local_endpoint` is allowed. Regional HTTPS connectivity and actual effective IAM still need qualification. +- Exact HTTP `200` on `/health` after startup validation, plus direct SIGTERM/request draining. Defaults are a 10-second application grace and 30-second container stop timeout. One stop-first task permits update downtime; this is not high availability. + +## Browser hostname and consent qualification + +The current fixtures use `publisher.example.com` and `ts-demo.example.com`. A response viewed at `https://ts-demo.example.com` cannot set an EC cookie scoped to `.publisher.example.com`; the browser rejects it. EC derives its cookie domain from `publisher.domain`, not `publisher.cookie_domain`. Changing only `cookie_domain` or fixing `X-Forwarded-Host` does not solve that rejection. The browser's visible response URL governs cookie acceptance. + +Existing Terraform constraints require different publisher and demo hostnames. This example does not change those values or validations. The operator must supply an approved cookie-compatible hostname configuration within existing constraints, or use the existing publisher-preserving browser proxy. In that proxy session the browser stays on the publisher URL while the proxy connects to the demo endpoint and rewrites the upstream Host. With CloudFront, `preserve_publisher_hostname = true` fixes `X-Forwarded-Host` to the configured publisher for this mode. It does not change browser origin, aliases, certificates, ALB Host/credential admission, Geo trust or no-shared-cache policy. Ordinary demo-host browsing retains the default `false` and is not proof of EC cookie acceptance. + +Consent runtime already extracts and enforces standard signals, including TCF, GPP, US Privacy and GPC. An empty `[integrations]` table is not necessarily a bug. An existing publisher CMP may already transport genuine signals. It is not evidence that transport works either. The operator owns genuine CMP signal transport for the selected browser path; this example adds no CMP integration and never fabricates consent or relaxes policy. The existing Sourcepoint GPP capture and `POST /_ts/consent` update path are a scoped example, not a generic TCF bridge or proof that another CMP's updates reach the server. + +In separately authorized browser acceptance, retain evidence for: + +1. The visible URL, origin/TLS, forwarding behavior, and the actual `Set-Cookie` domain. Inspect browser rejection reasons, stored `ts-ec`, and its return on the next eligible navigation. A response header alone is insufficient. +2. Genuine initial CMP decisions reaching native requests in the correct jurisdiction, including the no-consent case. Protected geography does not grant consent. +3. A real CMP update reaching the server and affecting subsequent behavior. Verify the chosen transport rather than assuming a client-side UI change propagated. +4. Withdrawal reaching the server, durable tombstone acknowledgement and browser-cookie expiry, with no stale update reviving identity. Verify persistence across task replacement separately. + +## Secrets, updates and rollback limits + +Application and Agent secrets use immutable Secrets Manager ARN/version references. Values do not belong in TOML, AppConfig or Terraform application inputs. The private CloudFront origin credential is an infrastructure exception and enters restricted Terraform state/plans despite `sensitive = true`. Keep its removal-header name stable across eligible rollback tasks. Operational saved-state/header continuity checks belong to the separate deployer; direct use of this example is not equivalent to that policy. + +Configuration and secrets are immutable startup snapshots. Changes require new prepared tasks. ECS task rollback does not restore the shared latest AppConfig document; the operator must reconcile compatible config, secret versions, provider selection and image manually. Older binaries may reject `[ec.storage]`. Stopping persistence or switching namespaces is not a safe rollback for issued identities and withdrawal obligations. Rollback automation is deferred. + +## Offline checks + +Use Terraform `1.16.2`, locked AWS provider `6.64.0` and the repository's pinned Node. From the repository root: + +```sh +terraform -chdir=deploy/examples/native-aws-workload fmt -check -recursive +terraform -chdir=deploy/examples/native-aws-workload init -backend=false -input=false -lockfile=readonly +terraform -chdir=deploy/examples/native-aws-workload validate +terraform -chdir=deploy/examples/native-aws-workload/retained init -backend=false -input=false -lockfile=readonly +terraform -chdir=deploy/examples/native-aws-workload/retained validate +env -u NODE_OPTIONS -u NODE_PATH node deploy/examples/native-aws-workload/test-draft.mjs +node --test deploy/examples/native-aws-workload/test-workflow.mjs +``` + +Backend-disabled initialization prepares provider tooling, not a live backend or AWS resources; it may need a provider download or an existing local mirror. When using an external `TF_DATA_DIR`, pass the same absolute directory to initialization and the helper. The helper copies an explicit public-file allowlist into disposable roots, strips inherited credentials/provider configuration, and uses a filesystem-only provider mirror. It never reads operator overrides, real state or private tfvars. Its mocked plans include the retained root, closed workload gate, main CloudFront tfvars, explicit Geo Off, storage mismatches, scoped IAM, routing phases and a fictional gate-only delete plan. No cloud resource is applied. + +Separately, with the repository's pinned Rust/Node toolchains, the Linux host target and build dependencies installed, check both committed envelopes through the actual runtime config loader: + +```sh +cargo test -p trusted-server-core --target x86_64-unknown-linux-gnu --test aws_example_config +``` + +This check verifies envelope integrity, settings, secret-reference resolution with fictional values and native EC selection. It does not connect to AWS or prepare a real DynamoDB table. Keep the dependencies cached for a disconnected run; first-time dependency installation is separate from these checks. + +The offline workflow has read-only repository permissions and no Environment, OIDC or cloud credentials. Schema/mock success does not prove real IAM, Agent startup, Fargate volume ownership, TLS/DNS, credential stripping, Geo, browser behavior or complete cleanup. Local persistence checks and later qualification requirements are in [the persistence guide](../../../docs/guide/native-ec-persistence.md#qualification-before-deployment). + +## Retirement and deferred production work + +The named operator owns ongoing charges, image updates, deliberate shutdown and manual reconciliation. Deletion is explicit, not expiry. Workload teardown removes its role/policy attachment, not the retained table. The retained owner separately approves namespace retirement, identity erasure, allocator continuity and eventual table removal. `prevent_destroy` and deletion protection are not backups and do not prevent authorized task-row deletion. Externally supplied state infrastructure, boundaries, certificates, zones and application secrets remain independently owned. Logs follow their selected retention/deletion setting; external audit records survive workload teardown. + +PITR/backups and restore qualification, alarms, HA, autoscaling, private networking/NAT/endpoints, WAF, customer-managed encryption keys, IPv6, image automation, bidder/partner integrations, signing/admin/S2S additions and rollback automation are deferred. Review retention/erasure and allocator continuity before choosing backups; capacity and failure behavior before adding replicas or tasks; routing and endpoint policies before changing egress; and trust/consent boundaries before adding integrations. None of those production measures is implemented or qualified by this example. diff --git a/deploy/examples/native-aws-workload/config.tf b/deploy/examples/native-aws-workload/config.tf new file mode 100644 index 000000000..7db9eee53 --- /dev/null +++ b/deploy/examples/native-aws-workload/config.tf @@ -0,0 +1,59 @@ +resource "aws_appconfig_application" "review" { + name = var.run_id + description = "Disposable reviewed TS config; no inline secret values" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_appconfig_environment" "review" { + application_id = aws_appconfig_application.review.id + name = var.run_id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_appconfig_configuration_profile" "review" { + application_id = aws_appconfig_application.review.id + name = "${var.run_id}-release" + location_uri = "hosted" + type = "AWS.Freeform" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_appconfig_hosted_configuration_version" "review" { + application_id = aws_appconfig_application.review.id + configuration_profile_id = aws_appconfig_configuration_profile.review.configuration_profile_id + content_type = "application/json" + content = local.envelope_text + description = "Reviewed CLI envelope; transport sha256 ${sha256(local.envelope_text)}" + depends_on = [terraform_data.draft_gate] + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_appconfig_deployment_strategy" "review" { + name = "${var.run_id}-initial" + deployment_duration_in_minutes = 0 + final_bake_time_in_minutes = 0 + growth_factor = 100 + growth_type = "LINEAR" + replicate_to = "NONE" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_appconfig_deployment" "review" { + application_id = aws_appconfig_application.review.id + environment_id = aws_appconfig_environment.review.environment_id + configuration_profile_id = aws_appconfig_configuration_profile.review.configuration_profile_id + configuration_version = aws_appconfig_hosted_configuration_version.review.version_number + deployment_strategy_id = aws_appconfig_deployment_strategy.review.id + description = "Initial immutable review release for ${var.run_id}" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_cloudwatch_log_group" "review" { + name = "/trusted-server/review/${var.run_id}" + retention_in_days = var.log_retention_in_days + skip_destroy = var.retain_logs_on_destroy + depends_on = [terraform_data.draft_gate] +} diff --git a/deploy/examples/native-aws-workload/ecs.tf b/deploy/examples/native-aws-workload/ecs.tf new file mode 100644 index 000000000..375c2b609 --- /dev/null +++ b/deploy/examples/native-aws-workload/ecs.tf @@ -0,0 +1,201 @@ +locals { + log_configuration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.review.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "review" + } + } + application_environment = merge({ + RUST_LOG = var.application_log_filter + AWS_REGION = var.aws_region + AWS_DEFAULT_REGION = var.aws_region + AWS_EC2_METADATA_DISABLED = "true" + EDGEZERO__ADAPTER__HOST = "0.0.0.0" + EDGEZERO__ADAPTER__PORT = "8787" + EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS = tostring(var.app_shutdown_grace_seconds) + EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS = join(",", local.trusted_proxy_cidrs) + EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY = "x-forwarded" + EDGEZERO__STORE_BINDINGS_FILE = "/run/trusted-server/native-bindings.toml" + TS_NATIVE_BINDINGS_TOML = local.native_bindings + # Release metadata forces a task-definition revision when config changes. + # These fields do not make the Agent read a historical configuration. + TS_REVIEW_CONFIG_TRANSPORT_SHA256 = sha256(local.envelope_text) + TS_REVIEW_APPCONFIG_VERSION = tostring(aws_appconfig_hosted_configuration_version.review.version_number) + TS_REVIEW_APPCONFIG_DEPLOYMENT = tostring(aws_appconfig_deployment.review.deployment_number) + }, local.geo_enabled ? { + TS_NATIVE_GEO_PROFILE_TOML = local.native_geo_profile + } : {}) + containers = [ + { + name = "app" + image = var.application_image + essential = true + user = "10001:10001" + readonlyRootFilesystem = true + stopTimeout = var.container_stop_timeout_seconds + portMappings = [{ containerPort = 8787, hostPort = 8787, protocol = "tcp" }] + # Match ECS's empty-array representation so a phase-only change does not + # replace an otherwise identical task definition after an AWS refresh. + linuxParameters = { capabilities = { add = [], drop = ["ALL"] } } + systemControls = [] + volumesFrom = [] + mountPoints = [{ sourceVolume = "runtime-bindings", containerPath = "/run/trusted-server", readOnly = false }] + environment = [for key in sort(keys(local.application_environment)) : { name = key, value = local.application_environment[key] }] + secrets = [{ name = "TS_APPCONFIG_TOKEN", valueFrom = local.token_value_from }] + # START is ordering only, not a fabricated readiness probe. The accepted + # provider/image must qualify cold Agent startup within bounded reads. + dependsOn = [{ containerName = "appconfig-agent", condition = "START" }] + logConfiguration = local.log_configuration + }, + { + name = "appconfig-agent" + image = var.agent_image + essential = true + readonlyRootFilesystem = true + stopTimeout = var.container_stop_timeout_seconds + linuxParameters = { capabilities = { add = [], drop = ["ALL"] } } + systemControls = [] + volumesFrom = [] + mountPoints = [] + portMappings = [] + environment = [ + { name = "HTTP_HOST", value = "127.0.0.1" }, + { name = "HTTP_PORT", value = "2772" }, + { name = "LOG_LEVEL", value = var.agent_log_level }, + { name = "REQUEST_TIMEOUT", value = "3000ms" }, + { name = "POLL_INTERVAL", value = "60s" }, + { name = "PRELOAD_BACKUPS", value = "false" }, + { name = "PREFETCH_LIST", value = "${aws_appconfig_application.review.id}:${aws_appconfig_environment.review.environment_id}:${aws_appconfig_configuration_profile.review.configuration_profile_id}" } + ] + # BACKUP_DIRECTORY and MANIFEST deliberately absent. PRELOAD_BACKUPS=false + # alone would still permit disk-backup fallback if a directory were set. + secrets = [{ name = "ACCESS_TOKEN", valueFrom = local.token_value_from }] + logConfiguration = local.log_configuration + } + ] +} + +resource "aws_ecs_cluster" "review" { + name = var.run_id + setting { + name = "containerInsights" + value = "disabled" + } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_ecs_task_definition" "review" { + family = var.run_id + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.task_cpu) + memory = tostring(var.task_memory) + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + volume { + name = "runtime-bindings" + } + container_definitions = jsonencode(local.containers) + depends_on = [terraform_data.draft_gate] +} + +// Retire the old service before its final listener association is removed. +// Keeping a zero-desired service would still require that association for every +// ECS UpdateService, including the scale-down itself. +resource "aws_ecs_service" "review" { + count = local.geo_enabled ? 0 : 1 + name = var.run_id + cluster = aws_ecs_cluster.review.id + task_definition = aws_ecs_task_definition.review.arn + desired_count = local.service_settings.desired_count + launch_type = "FARGATE" + platform_version = "1.4.0" + enable_execute_command = false + wait_for_steady_state = true + health_check_grace_period_seconds = local.service_settings.health_check_grace_period_seconds + deployment_minimum_healthy_percent = local.service_settings.deployment_minimum_healthy_percent + deployment_maximum_percent = local.service_settings.deployment_maximum_percent + deployment_circuit_breaker { + enable = true + rollback = true + } + network_configuration { + subnets = [aws_subnet.task.id] + security_groups = [aws_security_group.task.id] + assign_public_ip = true + } + load_balancer { + target_group_arn = aws_lb_target_group.review.arn + container_name = "app" + container_port = 8787 + } + timeouts { + create = "15m" + update = "15m" + delete = "15m" + } + depends_on = [ + terraform_data.draft_gate, + aws_appconfig_deployment.review, + aws_lb_listener_rule.publisher, + aws_vpc_security_group_egress_rule.auction, + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_route_table_association.public, + aws_route.internet + ] +} + +// Separate service history prevents ECS rollback from selecting an old Off task. +resource "aws_ecs_service" "geo" { + count = local.geo_enabled ? 1 : 0 + name = "${var.run_id}-geo" + cluster = aws_ecs_cluster.review.id + task_definition = aws_ecs_task_definition.review.arn + desired_count = local.service_settings.desired_count + launch_type = "FARGATE" + platform_version = "1.4.0" + enable_execute_command = false + wait_for_steady_state = true + health_check_grace_period_seconds = local.service_settings.health_check_grace_period_seconds + deployment_minimum_healthy_percent = local.service_settings.deployment_minimum_healthy_percent + deployment_maximum_percent = local.service_settings.deployment_maximum_percent + deployment_circuit_breaker { + enable = true + rollback = true + } + network_configuration { + subnets = [aws_subnet.task.id] + security_groups = [aws_security_group.task.id] + assign_public_ip = true + } + load_balancer { + target_group_arn = aws_lb_target_group.geo[0].arn + container_name = "app" + container_port = 8787 + } + timeouts { + create = "15m" + update = "15m" + delete = "15m" + } + depends_on = [ + terraform_data.draft_gate, + aws_appconfig_deployment.review, + aws_lb_listener_rule.geo, + aws_lb_listener_rule.publisher, + aws_vpc_security_group_ingress_rule.cloudfront, + aws_vpc_security_group_ingress_rule.reviewers, + aws_vpc_security_group_egress_rule.auction, + aws_iam_role_policy.execution, + aws_iam_role_policy.task, + aws_route_table_association.public, + aws_route.internet + ] +} diff --git a/deploy/examples/native-aws-workload/example-config.toml b/deploy/examples/native-aws-workload/example-config.toml new file mode 100644 index 000000000..3317d8f09 --- /dev/null +++ b/deploy/examples/native-aws-workload/example-config.toml @@ -0,0 +1,25 @@ +# Fictional, reference-only configuration. Replace with a reviewed audit output. +# Generate the AppConfig BlobEnvelope with the existing TS CLI, not Terraform. +[publisher] +domain = "publisher.example.com" +origin_url = "https://origin.publisher.example.com" +cookie_domain = ".publisher.example.com" +proxy_secret = "publisher_proxy_secret" + +[ec] +passphrase = "ec_passphrase" + +# Copy the retained root's ec_storage output into this table and workload inputs. +[ec.storage] +provider = "dynamodb" +region = "eu-west-2" +table = "example-retained-ec" +namespace = "example-staging" + +# Rust regex has no negative lookahead. These alternatives match every path +# beginning '/' EXCEPT the exact '/health'. Review and test the actual policy; +# the health handler is a native readiness route, not an ALB 401 exception. +[[handlers]] +path = '^/(?:$|[^h]|h(?:$|[^e])|he(?:$|[^a])|hea(?:$|[^l])|heal(?:$|[^t])|healt(?:$|[^h])|health.)' +username = "reviewer" +password = "handler_password" diff --git a/deploy/examples/native-aws-workload/example-envelope.json b/deploy/examples/native-aws-workload/example-envelope.json new file mode 100644 index 000000000..86c93d3f5 --- /dev/null +++ b/deploy/examples/native-aws-workload/example-envelope.json @@ -0,0 +1 @@ +{"data":{"publisher":{"domain":"publisher.example.com","cookie_domain":".publisher.example.com","origin_url":"https://origin.publisher.example.com","origin_host_header_override":null,"proxy_secret":"publisher_proxy_secret","max_buffered_body_bytes":16777216},"tester_cookie":{"enabled":false,"max_age_seconds":2592000},"ec":{"passphrase":"ec_passphrase","ec_store":null,"storage":{"provider":"dynamodb","region":"eu-west-2","table":"example-retained-ec","namespace":"example-staging"},"pull_sync_concurrency":3,"cluster_trust_threshold":10,"cluster_recheck_secs":3600,"partners":[]},"integrations":{},"handlers":[{"path":"^/(?:$|[^h]|h(?:$|[^e])|he(?:$|[^a])|hea(?:$|[^l])|heal(?:$|[^t])|healt(?:$|[^h])|health.)","username":"reviewer","password":"handler_password"}],"response_headers":{},"request_signing":null,"rewrite":{"exclude_domains":[]},"auction":{"enabled":false,"providers":{},"bidders":{},"mediator":null,"timeout_ms":2000,"creative_store":"creative_store","allowed_context_keys":[]},"consent":{"mode":"interpreter","check_expiration":true,"max_consent_age_days":395,"gdpr":{"applies_in":["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"]},"us_states":{"privacy_states":["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"]},"us_privacy_defaults":{"notice_given":true,"lspa_covered":false,"gpc_implies_optout":true},"conflict_resolution":{"mode":"restrictive","freshness_threshold_days":30}},"cache":{"asset_rules":[]},"proxy":{"certificate_check":true,"allowed_domains":[],"asset_routes":[]},"creative_opportunities":null,"image_optimizer":{"profile_sets":{}},"tinybird":{"enabled":false,"api_host":"","auction_dataset":"auction_events_raw","access_enabled":false,"access_dataset":"access_logs_raw","access_sample_rate":0.0,"max_body_bytes":1048576},"debug":{"ja4_endpoint_enabled":false,"auction_html_comment":false,"inject_adm_for_testing":false}},"generated_at":"2026-11-12T20:51:41Z","sha256":"a4e0462b739f17b57ae2041baa19936c2dbde6ede9be017244cf301c476b22dd","version":1} \ No newline at end of file diff --git a/deploy/examples/native-aws-workload/example-noindex-envelope.json b/deploy/examples/native-aws-workload/example-noindex-envelope.json new file mode 100644 index 000000000..b9f623e04 --- /dev/null +++ b/deploy/examples/native-aws-workload/example-noindex-envelope.json @@ -0,0 +1 @@ +{"data":{"publisher":{"domain":"publisher.example.com","cookie_domain":".publisher.example.com","origin_url":"https://origin.publisher.example.com","origin_host_header_override":null,"proxy_secret":"publisher_proxy_secret","max_buffered_body_bytes":16777216},"tester_cookie":{"enabled":false,"max_age_seconds":2592000},"ec":{"passphrase":"ec_passphrase","ec_store":null,"storage":{"provider":"dynamodb","region":"eu-west-2","table":"example-retained-ec","namespace":"example-staging"},"pull_sync_concurrency":3,"cluster_trust_threshold":10,"cluster_recheck_secs":3600,"partners":[]},"integrations":{},"handlers":[{"path":"^/(?:$|[^h]|h(?:$|[^e])|he(?:$|[^a])|hea(?:$|[^l])|heal(?:$|[^t])|healt(?:$|[^h])|health.)","username":"reviewer","password":"handler_password"}],"response_headers":{"X-Robots-Tag":"noindex"},"request_signing":null,"rewrite":{"exclude_domains":[]},"auction":{"enabled":false,"providers":{},"bidders":{},"mediator":null,"timeout_ms":2000,"creative_store":"creative_store","allowed_context_keys":[]},"consent":{"mode":"interpreter","check_expiration":true,"max_consent_age_days":395,"gdpr":{"applies_in":["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"]},"us_states":{"privacy_states":["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"]},"us_privacy_defaults":{"notice_given":true,"lspa_covered":false,"gpc_implies_optout":true},"conflict_resolution":{"mode":"restrictive","freshness_threshold_days":30}},"cache":{"asset_rules":[]},"proxy":{"certificate_check":true,"allowed_domains":[],"asset_routes":[]},"creative_opportunities":null,"image_optimizer":{"profile_sets":{}},"tinybird":{"enabled":false,"api_host":"","auction_dataset":"auction_events_raw","access_enabled":false,"access_dataset":"access_logs_raw","access_sample_rate":0.0,"max_body_bytes":1048576},"debug":{"ja4_endpoint_enabled":false,"auction_html_comment":false,"inject_adm_for_testing":false}},"generated_at":"2026-11-12T20:51:41Z","sha256":"46c547bdfde4fa801bcf0ab4b6630a81cdb552dc84fbc9b0ee59b7c9279274ca","version":1} \ No newline at end of file diff --git a/deploy/examples/native-aws-workload/geo.tf b/deploy/examples/native-aws-workload/geo.tf new file mode 100644 index 000000000..62c08d6ac --- /dev/null +++ b/deploy/examples/native-aws-workload/geo.tf @@ -0,0 +1,167 @@ +locals { + # Only presence is public. The credential name/value remain sensitive. + geo_enabled = nonsensitive(var.geo_front_door != null) + geo_phase = local.geo_enabled ? nonsensitive(var.geo_front_door.rollout_phase) : null + geo_credential_header_name = local.geo_enabled ? var.geo_front_door.origin_credential_header_name : null + trusted_proxy_cidrs = concat( + local.alb_subnet_cidrs, + local.geo_enabled ? sort(tolist(var.geo_front_door.cloudfront_ipv4_cidrs)) : [] + ) + native_geo_profile = local.geo_enabled ? join("\n", [ + "version = 1", + "", + "[geo]", + "provider = \"cloudfront\"", + "alb_peer_cidrs = ${jsonencode(local.alb_subnet_cidrs)}", + "origin_credential_header_name = ${jsonencode(local.geo_credential_header_name)}", + "" + ]) : null +} + +# The managed prefix list owns network ingress. Supplied literal ranges above +# own checked XFF trust, not Geo admission; keep them reviewed/versioned together. +data "aws_ec2_managed_prefix_list" "cloudfront" { + count = local.geo_enabled ? 1 : 0 + name = "com.amazonaws.global.cloudfront.origin-facing" +} + +resource "aws_vpc_security_group_ingress_rule" "cloudfront" { + count = local.geo_enabled ? 1 : 0 + security_group_id = aws_security_group.alb.id + prefix_list_id = data.aws_ec2_managed_prefix_list.cloudfront[0].id + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + depends_on = [terraform_data.draft_gate] +} + +# Never share this target group with the old Off service or its rollback history. +resource "aws_lb_target_group" "geo" { + count = local.geo_enabled ? 1 : 0 + name = "${var.run_id}-geo" + vpc_id = aws_vpc.review.id + target_type = "ip" + port = 8787 + protocol = "HTTP" + deregistration_delay = var.target_deregistration_delay_seconds + health_check { + enabled = local.target_health.enabled + path = local.target_health.path + protocol = local.target_health.protocol + matcher = local.target_health.matcher + interval = local.target_health.interval + timeout = local.target_health.timeout + healthy_threshold = local.target_health.healthy_threshold + unhealthy_threshold = local.target_health.unhealthy_threshold + } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_lb_listener_rule" "geo" { + count = local.geo_enabled ? 1 : 0 + listener_arn = aws_lb_listener.https.arn + priority = 90 + action { + type = "forward" + target_group_arn = aws_lb_target_group.geo[0].arn + } + condition { + host_header { values = [var.demo_hostname] } + } + condition { + http_header { + http_header_name = local.geo_credential_header_name + values = [var.geo_front_door.origin_credential_value] + } + } + depends_on = [terraform_data.draft_gate] +} + +# Explicitly request generated assertions. Viewer-header forwarding alone does +# not request CloudFront's geographic metadata. All viewer headers also preserve +# Host and admin Authorization. Cookies/query strings carry real consent state. +resource "aws_cloudfront_origin_request_policy" "geo" { + count = local.geo_enabled ? 1 : 0 + name = "${var.run_id}-geo" + headers_config { + header_behavior = "allViewerAndWhitelistCloudFront" + headers { items = ["CloudFront-Viewer-Country", "CloudFront-Viewer-Country-Region"] } + } + cookies_config { cookie_behavior = "all" } + query_strings_config { query_string_behavior = "all" } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_cloudfront_cache_policy" "geo" { + count = local.geo_enabled ? 1 : 0 + name = "${var.run_id}-no-cache" + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_gzip = false + enable_accept_encoding_brotli = false + headers_config { header_behavior = "none" } + cookies_config { cookie_behavior = "none" } + query_strings_config { query_string_behavior = "none" } + } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_cloudfront_distribution" "geo" { + count = local.geo_enabled ? 1 : 0 + enabled = local.geo_phase != "staged" + is_ipv6_enabled = false + aliases = [var.demo_hostname] + comment = "${var.run_id} protected native geo" + wait_for_deployment = true + retain_on_delete = false + tags = local.tags + origin { + # Independent of the viewer DNS alias, including after cutover. + domain_name = aws_lb.review.dns_name + origin_id = "protected-alb" + custom_header { + name = local.geo_credential_header_name + value = var.geo_front_door.origin_credential_value + } + # ALB preserves this field, and trusted proxy normalization consumes it. + # Fix browser identity at deployment time, never trust the viewer value. + # Publisher identity is opt-in; TLS and ALB admission still use demo Host. + custom_header { + name = "X-Forwarded-Host" + value = var.preserve_publisher_hostname ? var.publisher_hostname : var.demo_hostname + } + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + default_cache_behavior { + target_origin_id = "protected-alb" + viewer_protocol_policy = "https-only" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.geo[0].id + origin_request_policy_id = aws_cloudfront_origin_request_policy.geo[0].id + compress = false + } + dynamic "custom_error_response" { + for_each = toset([400, 403, 404, 405, 414, 416, 500, 501, 502, 503, 504]) + content { + error_code = custom_error_response.value + error_caching_min_ttl = 0 + } + } + restrictions { + geo_restriction { restriction_type = "none" } + } + viewer_certificate { + acm_certificate_arn = var.geo_front_door.viewer_certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + depends_on = [terraform_data.draft_gate] +} diff --git a/deploy/examples/native-aws-workload/iam.tf b/deploy/examples/native-aws-workload/iam.tf new file mode 100644 index 000000000..10c482dc9 --- /dev/null +++ b/deploy/examples/native-aws-workload/iam.tf @@ -0,0 +1,129 @@ +locals { + # Plan-known so the external boundary owner can review the same scoped + # DynamoDB allows without depending on generated AppConfig resource IDs. + ec_table_arn = "arn:aws:dynamodb:${var.ec_storage.region}:${var.aws_account_id}:table/${var.ec_storage.table}" + ec_task_policy = { + Version = "2012-10-17" + Statement = [ + { + Sid = "ValidateExactEcTable" + Effect = "Allow" + Action = ["dynamodb:DescribeTable"] + Resource = local.ec_table_arn + }, + { + Sid = "IdentityRowsOnly" + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"] + Resource = local.ec_table_arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = ["ec:${var.ec_storage.namespace}"] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + }, + { + Sid = "AllocateGenerationsOnly" + Effect = "Allow" + Action = ["dynamodb:UpdateItem"] + Resource = local.ec_table_arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = ["ec-allocator:${var.ec_storage.namespace}"] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + } + ] + } + + task_assume_role = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Condition = { + StringEquals = { "aws:SourceAccount" = var.aws_account_id } + ArnLike = { "aws:SourceArn" = "arn:aws:ecs:${var.aws_region}:${var.aws_account_id}:*" } + } + }] + }) + ecr_matches = [for image in [var.application_image, var.agent_image] : try( + regex("^([0-9]{12})\\.dkr\\.ecr\\.([a-z0-9-]+)\\.amazonaws\\.com/(.+)@sha256:[0-9a-f]{64}$", image), + null + )] + private_ecr_arns = [for match in local.ecr_matches : "arn:aws:ecr:${match[1]}:${match[0]}:repository/${match[2]}" if match != null] + kms_read_statement = length(var.secrets_kms_key_arns) == 0 ? [] : [{ + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = sort(tolist(var.secrets_kms_key_arns)) + Condition = { + StringEquals = { "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" } + } + }] +} + +resource "aws_iam_role" "execution" { + name = "${var.run_id}-execution" + permissions_boundary = var.runtime_boundaries.execution + assume_role_policy = local.task_assume_role + depends_on = [terraform_data.draft_gate] +} + +resource "aws_iam_role_policy" "execution" { + name = "${var.run_id}-execution" + role = aws_iam_role.execution.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat([ + { + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = ["${aws_cloudwatch_log_group.review.arn}:*"] + }, + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = [var.agent_token.arn] + } + ], [for statement in [ + { + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] + Resource = "*" + }, + { + Effect = "Allow" + Action = ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"] + Resource = local.private_ecr_arns + } + ] : statement if length(local.private_ecr_arns) > 0], local.kms_read_statement) + }) + depends_on = [terraform_data.draft_gate] +} + +resource "aws_iam_role" "task" { + name = "${var.run_id}-task" + permissions_boundary = var.runtime_boundaries.task + assume_role_policy = local.task_assume_role + depends_on = [terraform_data.draft_gate] +} + +resource "aws_iam_role_policy" "task" { + name = "${var.run_id}-runtime-read" + role = aws_iam_role.task.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat([ + { + Effect = "Allow" + Action = ["appconfig:StartConfigurationSession", "appconfig:GetLatestConfiguration"] + Resource = ["arn:aws:appconfig:${var.aws_region}:${var.aws_account_id}:application/${aws_appconfig_application.review.id}/environment/${aws_appconfig_environment.review.environment_id}/configuration/${aws_appconfig_configuration_profile.review.configuration_profile_id}"] + }, + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = sort(distinct([for ref in values(var.application_secrets) : ref.arn])) + } + ], local.kms_read_statement, local.ec_task_policy.Statement) + }) + depends_on = [terraform_data.draft_gate] +} diff --git a/deploy/examples/native-aws-workload/locals.tf b/deploy/examples/native-aws-workload/locals.tf new file mode 100644 index 000000000..ff2fbfba2 --- /dev/null +++ b/deploy/examples/native-aws-workload/locals.tf @@ -0,0 +1,80 @@ +locals { + # These missing qualifications cannot be removed through tfvars. Every AWS + # resource depends on draft_gate. Remove a blocker only with reviewed proof. + deployment_blockers = toset([ + "scoped-operator-and-locked-state-qualification", + "provider-enabled-final-image-and-nonroot-volume-runtime", + "alb-forwarding-host-scheme-auth-and-health-acceptance" + ]) + + tags = merge(var.extra_tags, { + Application = "trusted-server" + Purpose = "disposable-review" + RunId = var.run_id + Owner = var.owner + Lifecycle = "manual" + }) + + # Both ingress modes use these settings without sharing resource identities. + service_settings = { + desired_count = 1 + health_check_grace_period_seconds = var.ecs_health_grace_seconds + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + } + target_health = { + enabled = true + path = "/health" + protocol = "HTTP" + matcher = "200" + interval = var.health_interval_seconds + timeout = var.health_timeout_seconds + healthy_threshold = var.healthy_threshold + unhealthy_threshold = var.unhealthy_threshold + } + + envelope_text = file(var.config_envelope_file) + envelope = try(jsondecode(local.envelope_text), null) + app_config = try(local.envelope.data, {}) + # Terraform checks publication shape, website and retained storage bindings. + # The matching TS CLI/runtime owns authentication, signing, origin protocols, + # secret-consumer semantics and the application checksum. + envelope_valid = try( + local.envelope.version == 1 && + can(regex("^[0-9a-f]{64}$", local.envelope.sha256)) && + local.app_config.publisher.domain == var.publisher_hostname && + jsonencode(local.app_config.ec.storage) == jsonencode(var.ec_storage), + false + ) + + alb_subnet_cidrs = [cidrsubnet(var.vpc_cidr, 8, 0), cidrsubnet(var.vpc_cidr, 8, 1)] + task_subnet_cidr = cidrsubnet(var.vpc_cidr, 8, 10) + token_value_from = "${var.agent_token.arn}:::${var.agent_token.version_id}" + + # jsonencode supplies quoted/escaped strings valid in this restricted TOML + # representation. Only identifiers/mappings belong here, never secret values. + native_bindings = join("\n", concat([ + "version = 1", + "", + "[config.trusted_server_config]", + "provider = \"aws-appconfig-agent\"", + "default_key = \"trusted_server_config\"", + "endpoint = \"http://127.0.0.1:2772\"", + "access_token_env = \"TS_APPCONFIG_TOKEN\"", + "", + "[config.trusted_server_config.documents.trusted_server_config]", + "application = ${jsonencode(aws_appconfig_application.review.id)}", + "environment = ${jsonencode(aws_appconfig_environment.review.environment_id)}", + "profile = ${jsonencode(aws_appconfig_configuration_profile.review.configuration_profile_id)}", + "", + "[secrets.trusted_server_secrets]", + "provider = \"aws-secrets-manager\"", + "region = ${jsonencode(var.aws_region)}", + "" + ], flatten([for key, ref in var.application_secrets : [ + "[secrets.trusted_server_secrets.entries.${jsonencode(key)}]", + "secret_id = ${jsonencode(ref.arn)}", + "version_id = ${jsonencode(ref.version_id)}", + "" + ]]))) +} diff --git a/deploy/examples/native-aws-workload/network.tf b/deploy/examples/native-aws-workload/network.tf new file mode 100644 index 000000000..b85808b77 --- /dev/null +++ b/deploy/examples/native-aws-workload/network.tf @@ -0,0 +1,207 @@ +resource "aws_vpc" "review" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + tags = { Name = var.run_id } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_internet_gateway" "review" { + vpc_id = aws_vpc.review.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_subnet" "alb" { + count = 2 + vpc_id = aws_vpc.review.id + availability_zone = var.availability_zones[count.index] + cidr_block = local.alb_subnet_cidrs[count.index] + map_public_ip_on_launch = false + tags = { Name = "${var.run_id}-alb-${count.index}" } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_subnet" "task" { + vpc_id = aws_vpc.review.id + availability_zone = var.availability_zones[0] + cidr_block = local.task_subnet_cidr + map_public_ip_on_launch = false + tags = { Name = "${var.run_id}-task" } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.review.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_route" "internet" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.review.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_route_table_association" "public" { + for_each = { alb0 = aws_subnet.alb[0].id, alb1 = aws_subnet.alb[1].id, task = aws_subnet.task.id } + subnet_id = each.value + route_table_id = aws_route_table.public.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_security_group" "alb" { + name_prefix = "${var.run_id}-alb-" + description = "Reviewer HTTPS only; backend traffic limited to the task SG" + vpc_id = aws_vpc.review.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_security_group" "task" { + name_prefix = "${var.run_id}-task-" + description = "Only owned ALB ingress; no public application port" + vpc_id = aws_vpc.review.id + depends_on = [terraform_data.draft_gate] +} + +resource "aws_vpc_security_group_ingress_rule" "reviewers" { + for_each = local.geo_enabled ? toset([]) : var.reviewer_ipv4_cidrs + security_group_id = aws_security_group.alb.id + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv4 = each.value + depends_on = [terraform_data.draft_gate] +} + +resource "aws_vpc_security_group_egress_rule" "alb_to_task" { + security_group_id = aws_security_group.alb.id + referenced_security_group_id = aws_security_group.task.id + ip_protocol = "tcp" + from_port = 8787 + to_port = 8787 + depends_on = [terraform_data.draft_gate] +} + +resource "aws_vpc_security_group_ingress_rule" "alb_to_task" { + security_group_id = aws_security_group.task.id + referenced_security_group_id = aws_security_group.alb.id + ip_protocol = "tcp" + from_port = 8787 + to_port = 8787 + depends_on = [terraform_data.draft_gate] +} + +resource "aws_vpc_security_group_egress_rule" "task_https" { + security_group_id = aws_security_group.task.id + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv4 = "0.0.0.0/0" + description = "Reviewed public HTTPS origin/AWS/registry egress; no NAT or VPC endpoints" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_vpc_security_group_egress_rule" "auction" { + count = var.auction_egress == null ? 0 : 1 + security_group_id = aws_security_group.task.id + ip_protocol = "tcp" + from_port = var.auction_egress.port + to_port = var.auction_egress.port + cidr_ipv4 = var.auction_egress.cidr_ipv4 + description = "Reviewed single-endpoint auction TCP egress" + depends_on = [terraform_data.draft_gate] +} + +resource "aws_lb" "review" { + name = var.run_id + internal = false + load_balancer_type = "application" + ip_address_type = "ipv4" + subnets = aws_subnet.alb[*].id + security_groups = [aws_security_group.alb.id] + preserve_host_header = true + drop_invalid_header_fields = true + desync_mitigation_mode = "strictest" + idle_timeout = var.alb_idle_timeout_seconds + enable_deletion_protection = false + depends_on = [terraform_data.draft_gate] +} + +resource "aws_lb_target_group" "review" { + name = "${var.run_id}-app" + vpc_id = aws_vpc.review.id + target_type = "ip" + port = 8787 + protocol = "HTTP" + deregistration_delay = var.target_deregistration_delay_seconds + health_check { + enabled = local.target_health.enabled + path = local.target_health.path + protocol = local.target_health.protocol + matcher = local.target_health.matcher + interval = local.target_health.interval + timeout = local.target_health.timeout + healthy_threshold = local.target_health.healthy_threshold + unhealthy_threshold = local.target_health.unhealthy_threshold + } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.review.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = var.certificate_arn + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "Not a configured review host" + status_code = "404" + } + } + depends_on = [terraform_data.draft_gate] +} + +# Accept both the publisher-preserving CLI flow and direct demo browsing. +# A normal browser does not supply X-Forwarded-Host. Keep the exact Host allowlist +# and the default 404; this rule is not a forwarding-header provenance check. +resource "aws_lb_listener_rule" "publisher" { + listener_arn = aws_lb_listener.https.arn + priority = 100 + dynamic "action" { + for_each = local.geo_enabled ? [] : [1] + content { + type = "forward" + target_group_arn = aws_lb_target_group.review.arn + } + } + dynamic "action" { + for_each = local.geo_enabled ? [1] : [] + content { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "Protected origin only" + status_code = "404" + } + } + } + condition { + host_header { values = [var.publisher_hostname, var.demo_hostname] } + } + depends_on = [terraform_data.draft_gate] +} + +resource "aws_route53_record" "review" { + zone_id = var.route53_zone_id + name = var.demo_hostname + type = "A" + alias { + name = local.geo_phase == "dns-cutover" ? aws_cloudfront_distribution.geo[0].domain_name : aws_lb.review.dns_name + zone_id = local.geo_phase == "dns-cutover" ? aws_cloudfront_distribution.geo[0].hosted_zone_id : aws_lb.review.zone_id + evaluate_target_health = local.geo_phase != "dns-cutover" + } + depends_on = [terraform_data.draft_gate] +} diff --git a/deploy/examples/native-aws-workload/outputs.tf b/deploy/examples/native-aws-workload/outputs.tf new file mode 100644 index 000000000..f2fa2e96c --- /dev/null +++ b/deploy/examples/native-aws-workload/outputs.tf @@ -0,0 +1,39 @@ +# Policy identity remains plan-known even when AppConfig makes task JSON unknown. +# This never projects the private credential value. +output "deployment_policy" { + sensitive = true + value = { + run_id = var.run_id + aws_account_id = var.aws_account_id + aws_region = var.aws_region + geo_enabled = local.geo_enabled + origin_credential_header_name = local.geo_enabled ? local.geo_credential_header_name : null + } +} + +output "ec_task_policy_json" { + description = "Plan-known scoped DynamoDB allow document for the separately owned task permissions boundary. Explicit denies in boundaries, SCPs or endpoint policies still apply." + value = jsonencode(local.ec_task_policy) +} + +output "draft_status" { + description = "False means no AWS workload may be applied. The operator must destroy this workload manually." + value = { + deployable = length(local.deployment_blockers) == 0 + blockers = sort(tolist(local.deployment_blockers)) + run_id = var.run_id + geo = local.geo_enabled ? { phase = local.geo_phase } : null + manual_shutdown_required = true + config_envelope_transport_sha256 = sha256(local.envelope_text) + external_inputs_retained_on_destroy = ["Retained DynamoDB table and allocator rows", "Supplied Secrets Manager secret versions", "ACM certificates", "Route53 hosted zone", "Published images", "Separately bootstrapped remote state/operator authority"] + } +} + +output "planned_review_access" { + description = "Planned address, not evidence of a deployed or authenticated endpoint." + value = { + tls_endpoint = "https://${var.demo_hostname}" + publisher_host = var.publisher_hostname + proxy_mapping = "${var.publisher_hostname}=${var.demo_hostname}" + } +} diff --git a/deploy/examples/native-aws-workload/retained/.terraform.lock.hcl b/deploy/examples/native-aws-workload/retained/.terraform.lock.hcl new file mode 100644 index 000000000..38e6eaafe --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.64.0" + constraints = "6.64.0" + hashes = [ + "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", + "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", + "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", + "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", + "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", + "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", + "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", + "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", + "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", + "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", + "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", + "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", + "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", + "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", + "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", + ] +} diff --git a/deploy/examples/native-aws-workload/retained/main.tf b/deploy/examples/native-aws-workload/retained/main.tf new file mode 100644 index 000000000..872e4500f --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/main.tf @@ -0,0 +1,34 @@ +locals { + identity_partition = "ec:${var.namespace}" + allocator_partition = "ec-allocator:${var.namespace}" +} + +resource "aws_dynamodb_table" "ec" { + name = var.table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + range_key = "sk" + deletion_protection_enabled = true + + attribute { + name = "pk" + type = "S" + } + + attribute { + name = "sk" + type = "S" + } + + ttl { + attribute_name = "ttl_epoch_s" + enabled = true + } + + # Omit server_side_encryption to use DynamoDB's default AWS-owned key. + # Allocator rows never carry ttl_epoch_s. Retain them while the namespace + # can be reused; TTL applies only to expiring identity rows. + lifecycle { + prevent_destroy = true + } +} diff --git a/deploy/examples/native-aws-workload/retained/outputs.tf b/deploy/examples/native-aws-workload/retained/outputs.tf new file mode 100644 index 000000000..ebb05a156 --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/outputs.tf @@ -0,0 +1,22 @@ +output "table_arn" { + description = "Exact table ARN for operator and retained boundary review." + value = aws_dynamodb_table.ec.arn +} + +output "ec_storage" { + description = "Non-secret ec.storage object to publish in the existing Trusted Server app-config envelope. No credential or execution-role changes." + value = { + provider = "dynamodb" + region = var.aws_region + table = aws_dynamodb_table.ec.name + namespace = var.namespace + } +} + +output "owned_partitions" { + description = "Reserve both partition values for the named owner. Never reset the allocator while reusing the namespace." + value = { + identity = local.identity_partition + allocator = local.allocator_partition + } +} diff --git a/deploy/examples/native-aws-workload/retained/terraform.tf b/deploy/examples/native-aws-workload/retained/terraform.tf new file mode 100644 index 000000000..e6c720f64 --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/terraform.tf @@ -0,0 +1,32 @@ +terraform { + required_version = ">= 1.16.2, < 1.17.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.64.0" + } + } + + # Supply a retained bucket, installation-specific key and region at init. + # Never reuse the workload state key. The bucket and its access policy are + # owned separately from this example and the ECS workload. + backend "s3" { + encrypt = true + use_lockfile = true + } +} + +provider "aws" { + region = var.aws_region + allowed_account_ids = [var.aws_account_id] + + default_tags { + tags = { + Application = "trusted-server" + Owner = var.owner + Namespace = var.namespace + Lifecycle = "retained-ec-state" + } + } +} diff --git a/deploy/examples/native-aws-workload/retained/terraform.tfvars.example b/deploy/examples/native-aws-workload/retained/terraform.tfvars.example new file mode 100644 index 000000000..1e5cff202 --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/terraform.tfvars.example @@ -0,0 +1,6 @@ +# Illustrative identifiers only. Do not use a disposable workload ID here. +aws_account_id = "000000000000" +aws_region = "eu-west-2" +table_name = "example-retained-ec" +namespace = "example-staging" +owner = "example-platform-team" diff --git a/deploy/examples/native-aws-workload/retained/tests/retained.tftest.hcl b/deploy/examples/native-aws-workload/retained/tests/retained.tftest.hcl new file mode 100644 index 000000000..58be8756a --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/tests/retained.tftest.hcl @@ -0,0 +1,55 @@ +mock_provider "aws" { + override_during = plan + + mock_resource "aws_dynamodb_table" { + defaults = { + arn = "arn:aws:dynamodb:eu-west-2:000000000000:table/example-retained-ec" + } + } +} + +variables { + aws_account_id = "000000000000" + aws_region = "eu-west-2" + table_name = "example-retained-ec" + namespace = "example-staging" + owner = "example-platform-team" +} + +run "retained_table_contract" { + command = plan + + assert { + condition = ( + aws_dynamodb_table.ec.name == "example-retained-ec" && + aws_dynamodb_table.ec.billing_mode == "PAY_PER_REQUEST" && + aws_dynamodb_table.ec.hash_key == "pk" && + aws_dynamodb_table.ec.range_key == "sk" && + aws_dynamodb_table.ec.attribute == toset([{ name = "pk", type = "S" }, { name = "sk", type = "S" }]) + ) + error_message = "Retained EC storage requires only the string pk/sk table layout." + } + + assert { + condition = ( + aws_dynamodb_table.ec.deletion_protection_enabled && + one(aws_dynamodb_table.ec.ttl).enabled && + one(aws_dynamodb_table.ec.ttl).attribute_name == "ttl_epoch_s" && + length(aws_dynamodb_table.ec.server_side_encryption) == 0 + ) + error_message = "Keep deletion protection, identity TTL and default AWS-owned encryption." + } + + assert { + condition = output.ec_storage == { + provider = "dynamodb" + region = "eu-west-2" + table = "example-retained-ec" + namespace = "example-staging" + } && output.table_arn == aws_dynamodb_table.ec.arn && output.owned_partitions == { + identity = "ec:example-staging" + allocator = "ec-allocator:example-staging" + } + error_message = "The retained output must supply the workload and envelope storage binding plus both reserved partitions." + } +} diff --git a/deploy/examples/native-aws-workload/retained/variables.tf b/deploy/examples/native-aws-workload/retained/variables.tf new file mode 100644 index 000000000..116101e6f --- /dev/null +++ b/deploy/examples/native-aws-workload/retained/variables.tf @@ -0,0 +1,32 @@ +variable "aws_account_id" { + description = "Reviewed AWS account. The provider refuses another account." + type = string + validation { + condition = can(regex("^[0-9]{12}$", var.aws_account_id)) + error_message = "Supply a 12-digit AWS account ID." + } +} + +variable "aws_region" { + description = "Single approved commercial AWS region for both table and runtime." + type = string +} + +variable "table_name" { + description = "Stable retained table name, not an ephemeral workload/run name." + type = string +} + +variable "namespace" { + description = "Exclusive app/environment identity namespace. Reserve its ec: and ec-allocator: partitions together." + type = string +} + +variable "owner" { + description = "Named owner of data retention, allocator continuity, costs, state and approved decommissioning." + type = string + validation { + condition = length(trimspace(var.owner)) > 0 + error_message = "An accountable retention owner is required." + } +} diff --git a/deploy/examples/native-aws-workload/terraform.tf b/deploy/examples/native-aws-workload/terraform.tf new file mode 100644 index 000000000..19b5effe0 --- /dev/null +++ b/deploy/examples/native-aws-workload/terraform.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 1.16.2, < 1.17.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.64.0" + } + } + + # Supply a separately provisioned bucket, workload-specific key and region + # at init. Never reuse retained/ state or another workload's state. + backend "s3" { + encrypt = true + use_lockfile = true + } +} + +provider "aws" { + region = var.aws_region + allowed_account_ids = [var.aws_account_id] + + default_tags { + tags = local.tags + } +} + +# This is a draft, not an operator-overridable assurance checkbox. Replace this +# implementation gate only when all listed qualifications have reviewed proof, +# including the scoped operator role and persistent S3 state with lockfile locking. +resource "terraform_data" "draft_gate" { + input = { + run_id = var.run_id + } + + lifecycle { + precondition { + condition = length(local.deployment_blockers) == 0 && var.run_id != "" + error_message = "DRAFT ONLY. Unqualified: ${join(", ", sort(tolist(local.deployment_blockers)))}. No workload resource may be applied. ECS desired count zero is not teardown." + } + precondition { + condition = local.envelope_valid + error_message = "Supply a CLI BlobEnvelope with version 1, a SHA-256 checksum field, the selected publisher domain and ec.storage exactly matching ec_storage. Trusted Server validates application policy and checksum." + } + } +} diff --git a/deploy/examples/native-aws-workload/terraform.tfvars.example b/deploy/examples/native-aws-workload/terraform.tfvars.example new file mode 100644 index 000000000..4f1338e7d --- /dev/null +++ b/deploy/examples/native-aws-workload/terraform.tfvars.example @@ -0,0 +1,54 @@ +# Fictional inputs only. Not an apply recipe. Copy to an ignored local tfvars +# only after approvals; all AWS resources remain behind the hard draft gate. +run_id = "review-example" +owner = "review-owner" +aws_account_id = "000000000000" +aws_region = "eu-west-2" +availability_zones = ["eu-west-2a", "eu-west-2b"] +demo_hostname = "ts-demo.example.com" +publisher_hostname = "publisher.example.com" +# CloudFront only. Opt in for a publisher-origin browser proxy, not demo-host browsing. +preserve_publisher_hostname = false +route53_zone_id = "Z0000000000000" +certificate_arn = "arn:aws:acm:eu-west-2:000000000000:certificate/00000000-0000-0000-0000-000000000000" +reviewer_ipv4_cidrs = ["203.0.113.0/24"] +application_image = "000000000000.dkr.ecr.eu-west-2.amazonaws.com/review-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +agent_image = "public.ecr.aws/example/agent@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +config_envelope_file = "example-envelope.json" +# Copy the ec_storage output from the separately initialized retained/ root. +ec_storage = { + provider = "dynamodb" + region = "eu-west-2" + table = "example-retained-ec" + namespace = "example-staging" +} +# Fictional private origin credential and reviewed ranges. Replace before use. +geo_front_door = { + rollout_phase = "dns-cutover" + viewer_certificate_arn = "arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000" + origin_credential_header_name = "x-fictional-origin-check" + origin_credential_value = "fictional_only_00000000000000000000000000000000" + cloudfront_ipv4_cidrs = ["192.0.2.0/24", "198.51.100.0/24"] +} +application_secrets = { + handler_password = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-handler-AbCdEf" + version_id = "11111111111111111111111111111111" + } + publisher_proxy_secret = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-proxy-AbCdEf" + version_id = "22222222222222222222222222222222" + } + ec_passphrase = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-ec-AbCdEf" + version_id = "33333333333333333333333333333333" + } +} +runtime_boundaries = { + execution = "arn:aws:iam::000000000000:policy/native-demo/review-example/execution-boundary" + task = "arn:aws:iam::000000000000:policy/native-demo/review-example/task-boundary" +} +agent_token = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-agent-AbCdEf" + version_id = "44444444444444444444444444444444" +} diff --git a/deploy/examples/native-aws-workload/test-draft.mjs b/deploy/examples/native-aws-workload/test-draft.mjs new file mode 100644 index 000000000..3e8dd7a4c --- /dev/null +++ b/deploy/examples/native-aws-workload/test-draft.mjs @@ -0,0 +1,810 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const terraform = process.env.TERRAFORM_BIN || "terraform"; +const data = path.resolve(root, process.env.TF_DATA_DIR || ".terraform"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "ts-terraform-draft-")); +const sourceFiles = [ + "config.tf", + "ecs.tf", + "geo.tf", + "iam.tf", + "locals.tf", + "network.tf", + "outputs.tf", + "terraform.tf", + "variables.tf", +]; +const env = { + PATH: process.env.PATH, + HOME: path.join(temporary, "home"), + TMPDIR: temporary, + LANG: "C.UTF-8", + AWS_EC2_METADATA_DISABLED: "true", + TF_IN_AUTOMATION: "1", +}; +fs.mkdirSync(env.HOME); +function run(args, cwd, extra) { + const result = spawnSync(terraform, args, { + cwd, + env: { ...env, ...extra }, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 8 * 1024 * 1024, + }); + process.stdout.write(result.stdout || ""); + process.stderr.write(result.stderr || ""); + assert.equal( + result.error, + undefined, + "Terraform command must complete within the deadline", + ); + assert.equal(result.status, 0, `Terraform ${args.join(" ")} failed`); +} +try { + // Reject extra configuration without reading it. In particular, never pick up + // operator overrides, auto tfvars or any state/backend files in this checkout. + const configurations = fs + .readdirSync(root) + .filter((name) => name.endsWith(".tf") || name.endsWith(".tf.json")) + .sort(); + assert.deepEqual( + configurations, + sourceFiles, + "only reviewed public Terraform source belongs in the mock fixture", + ); + const localsPath = path.join(root, "locals.tf"); + const locals = fs.readFileSync(localsPath, "utf8"); + const gate = /deployment_blockers\s*=\s*toset\(\[[\s\S]*?\]\)/g; + assert.equal( + [...locals.matchAll(gate)].length, + 1, + "production source must contain one explicit qualification-blocker set", + ); + for (const blocker of [ + "scoped-operator-and-locked-state-qualification", + "provider-enabled-final-image-and-nonroot-volume-runtime", + "alb-forwarding-host-scheme-auth-and-health-acceptance", + ]) + assert.ok( + locals.includes(JSON.stringify(blocker)), + `required deployment blocker: ${blocker}`, + ); + const variables = fs.readFileSync(path.join(root, "variables.tf"), "utf8"); + const geoVariable = variables + .split('variable "geo_front_door" {')[1] + .split('variable "application_image" {')[0]; + assert.doesNotMatch( + geoVariable, + /\bdefault\s*=/, + "Geo must require an explicit profile or null, never silently select Off", + ); + assert.doesNotMatch( + fs.readFileSync(path.join(root, "ecs.tf"), "utf8"), + /^moved\s*\{/m, + "The new public root has no historical service address to migrate", + ); + let resources = 0; + for (const file of sourceFiles) { + const text = fs.readFileSync(path.join(root, file), "utf8"); + for (const match of text.matchAll( + /^resource "(aws_[^"]+)" "[^"]+" \{([\s\S]*?)(?=^resource |$(?![\s\S]))/gm, + )) { + resources++; + assert.match( + match[2], + /depends_on\s*=\s*\[[^\]]*terraform_data\.draft_gate/s, + `${file}: ${match[1]} must depend directly on the draft gate`, + ); + assert.notEqual( + match[1], + "aws_secretsmanager_secret_version", + "secret values must not enter Terraform state", + ); + } + } + assert.equal( + resources, + 40, + "all workload resource blocks must be directly gated", + ); + assert.ok( + fs.existsSync(path.join(data, "providers")), + "initialize the public provider with terraform init -backend=false first", + ); + const cliConfig = path.join(temporary, "mirror.tfrc"); + fs.writeFileSync( + cliConfig, + `provider_installation {\n filesystem_mirror {\n path = ${JSON.stringify(path.join(data, "providers"))}\n include = ["registry.terraform.io/hashicorp/aws"]\n }\n}\n`, + ); + for (const qualified of [false, true]) { + // Both plans run only public-source copies with fictional test variables, + // never the checkout's backend, state, credentials or automatic var files. + const fixture = path.join( + temporary, + qualified ? "task-contract" : "production-gate", + ); + fs.mkdirSync(path.join(fixture, "tests"), { recursive: true }); + for (const file of [ + ...sourceFiles, + ".terraform.lock.hcl", + "example-envelope.json", + "example-noindex-envelope.json", + ]) + fs.copyFileSync(path.join(root, file), path.join(fixture, file)); + const testFile = qualified + ? "task-contract.tftest.hcl.fixture" + : "draft.tftest.hcl"; + let contract = fs.readFileSync(path.join(root, "tests", testFile), "utf8"); + assert.match(contract, /^mock_provider "aws"/); + assert.doesNotMatch( + contract, + /command\s*=\s*apply/, + "offline checks must not apply even mocked workload", + ); + const geoInput = (phase = "staged", overrides = {}) => { + const fields = { + rollout_phase: phase, + viewer_certificate_arn: + "arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000", + origin_credential_header_name: "x-fictional-origin-check", + origin_credential_value: + "fictional_only_00000000000000000000000000000000", + cloudfront_ipv4_cidrs: ["192.0.2.0/24", "198.51.100.0/24"], + ...overrides, + }; + return `{${Object.entries(fields) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}`) + .join("\n")}}`; + }; + contract = contract.replace( + 'mock_provider "aws" {', + `mock_provider "aws" { + mock_data "aws_ec2_managed_prefix_list" { + override_during = plan + defaults = { id = "pl-0123456789abcdef0" } + } + mock_resource "aws_security_group" { + override_during = plan + defaults = { id = "sg-0123456789abcdef0" } + } + mock_resource "aws_lb" { + override_during = plan + defaults = { arn = "arn:aws:elasticloadbalancing:eu-west-2:000000000000:loadbalancer/app/review-example/0000000000000000", dns_name = "review-alb.example.com", zone_id = "Z0000000000001" } + } + mock_resource "aws_lb_target_group" { + override_during = plan + defaults = { arn = "arn:aws:elasticloadbalancing:eu-west-2:000000000000:targetgroup/review-example-geo/0000000000000000" } + } + mock_resource "aws_cloudfront_cache_policy" { + override_during = plan + defaults = { id = "00000000-0000-0000-0000-000000000001" } + } + mock_resource "aws_cloudfront_distribution" { + override_during = plan + defaults = { domain_name = "review-distribution.example.com", hosted_zone_id = "Z0000000000002" } + } + `, + ); + const ecs = fs.readFileSync(path.join(root, "ecs.tf"), "utf8"); + assert.match( + ecs, + /resource "aws_ecs_service" "geo" \{[\s\S]*?depends_on\s*=\s*\[[^\]]*aws_vpc_security_group_egress_rule\.auction/, + "Geo service must wait for the scoped auction egress rule", + ); + const check = (condition, message) => + `assert {\ncondition = ${condition}\nerror_message = ${JSON.stringify(message)}\n}\n`; + // Omit the new input for demo identity to exercise its actual default. + const phases = qualified + ? ["staged", "origin-enabled", "dns-cutover"] + : ["staged"]; + for (const { preservePublisher, phase } of [false, true].flatMap( + (preservePublisher) => + phases.map((phase) => ({ preservePublisher, phase })), + )) { + const forwardedHost = preservePublisher + ? "var.publisher_hostname" + : "var.demo_hostname"; + contract += `\nrun "geo_${phase.replaceAll("-", "_")}_${preservePublisher ? "publisher" : "demo_default"}" { + command = plan + variables { + geo_front_door = ${geoInput(phase)} + ${preservePublisher ? "preserve_publisher_hostname = true" : ""} + } + ${qualified ? "" : "expect_failures = [terraform_data.draft_gate]"} + ${ + qualified + ? [ + check( + `aws_cloudfront_distribution.geo[0].enabled == ${phase !== "staged"} && !aws_cloudfront_distribution.geo[0].is_ipv6_enabled && aws_cloudfront_distribution.geo[0].tags.RunId == var.run_id`, + "Traffic must be separate from opt-in; IPv6 is not enabled.", + ), + check( + `length(aws_ecs_service.review) == 0 && aws_ecs_service.geo[0].desired_count == 1 && aws_ecs_service.geo[0].name == "review-example-geo" && aws_lb_target_group.geo[0].name == "review-example-geo" && one(aws_ecs_service.geo[0].load_balancer).target_group_arn == aws_lb_target_group.geo[0].arn`, + "Old Off tasks must never join the credential-bearing target group.", + ), + check( + `one(aws_lb_listener_rule.publisher.action).type == "fixed-response" && length(aws_vpc_security_group_ingress_rule.reviewers) == 0 && aws_vpc_security_group_ingress_rule.cloudfront[0].prefix_list_id == data.aws_ec2_managed_prefix_list.cloudfront[0].id`, + "Geo opt-in must immediately remove the unprotected ALB bypass.", + ), + check( + `length(aws_lb_listener_rule.geo[0].condition) == 2 && anytrue([for c in aws_lb_listener_rule.geo[0].condition : try(one(c.http_header).http_header_name == var.geo_front_door.origin_credential_header_name && toset(one(c.http_header).values) == toset([var.geo_front_door.origin_credential_value]), false)]) && anytrue([for c in aws_lb_listener_rule.geo[0].condition : try(toset(one(c.host_header).values) == toset([var.demo_hostname]), false)]) && one(aws_lb_listener_rule.geo[0].action).target_group_arn == aws_lb_target_group.geo[0].arn`, + "Only exact credential plus demo Host may reach the isolated Geo targets.", + ), + check( + `strcontains(local.native_geo_profile, "alb_peer_cidrs = ") && strcontains(local.native_geo_profile, var.geo_front_door.origin_credential_header_name) && !strcontains(local.native_geo_profile, var.geo_front_door.origin_credential_value) && !strcontains(aws_ecs_task_definition.review.container_definitions, var.geo_front_door.origin_credential_value) && !strcontains(local.native_geo_profile, "192.0.2.0/24") && strcontains(local.application_environment.EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS, "192.0.2.0/24")`, + "Geo ALB peers and ordinary proxy networks must stay distinct, and the credential value must never reach ECS.", + ), + check( + `var.preserve_publisher_hostname == ${preservePublisher} && length(one(aws_cloudfront_distribution.geo[0].origin).custom_header) == 2 && anytrue([for h in one(aws_cloudfront_distribution.geo[0].origin).custom_header : h.name == "X-Forwarded-Host" && h.value == ${forwardedHost}]) && anytrue([for h in one(aws_cloudfront_distribution.geo[0].origin).custom_header : h.name == var.geo_front_door.origin_credential_header_name && h.value == var.geo_front_door.origin_credential_value])`, + "Origin custom headers must be exactly the private credential and deployment-fixed forwarded Host override, never arbitrary viewer values or extra headers.", + ), + check( + `toset(aws_cloudfront_distribution.geo[0].aliases) == toset([var.demo_hostname]) && one(aws_cloudfront_distribution.geo[0].viewer_certificate).acm_certificate_arn == var.geo_front_door.viewer_certificate_arn && aws_lb_listener.https.certificate_arn == var.certificate_arn && one(one(aws_lb_listener.https.default_action).fixed_response).status_code == "404"`, + "Publisher browser identity must not change demo TLS/DNS identity or fail-closed ALB admission.", + ), + check( + `one(aws_cloudfront_origin_request_policy.geo[0].headers_config).header_behavior == "allViewerAndWhitelistCloudFront" && toset(one(one(aws_cloudfront_origin_request_policy.geo[0].headers_config).headers).items) == toset(["CloudFront-Viewer-Country", "CloudFront-Viewer-Country-Region"]) && one(aws_cloudfront_origin_request_policy.geo[0].cookies_config).cookie_behavior == "all" && one(aws_cloudfront_origin_request_policy.geo[0].query_strings_config).query_string_behavior == "all"`, + "Generated Geo, Host, Authorization, cookies and queries must be explicitly forwarded.", + ), + check( + `aws_cloudfront_cache_policy.geo[0].min_ttl == 0 && aws_cloudfront_cache_policy.geo[0].default_ttl == 0 && aws_cloudfront_cache_policy.geo[0].max_ttl == 0 && length(aws_cloudfront_distribution.geo[0].custom_error_response) == 11 && alltrue([for e in aws_cloudfront_distribution.geo[0].custom_error_response : e.error_caching_min_ttl == 0])`, + "No response or error may be cached between viewers.", + ), + check( + `one(aws_cloudfront_distribution.geo[0].default_cache_behavior).viewer_protocol_policy == "https-only" && one(one(aws_cloudfront_distribution.geo[0].origin).custom_origin_config).origin_protocol_policy == "https-only" && one(aws_cloudfront_distribution.geo[0].origin).domain_name == aws_lb.review.dns_name && one(aws_route53_record.review.alias).evaluate_target_health == ${phase !== "dns-cutover"}`, + "Both public hops require HTTPS; origin DNS must not recurse into the viewer alias.", + ), + check( + `one(aws_route53_record.review.alias).name == ${phase === "dns-cutover" ? "aws_cloudfront_distribution.geo[0].domain_name" : "aws_lb.review.dns_name"}`, + "DNS must cut over only after separate protected-origin acceptance.", + ), + ].join("\n") + : "" + } + }\n`; + } + contract += `\nrun "auction_egress_gated" { + command = plan + variables { auction_egress = { cidr_ipv4 = "192.0.2.80/32", port = 8000 } } + ${qualified ? "" : "expect_failures = [terraform_data.draft_gate]"} + ${qualified ? check('length(aws_vpc_security_group_egress_rule.auction) == 1 && aws_vpc_security_group_egress_rule.auction[0].security_group_id == aws_security_group.task.id && aws_vpc_security_group_egress_rule.auction[0].ip_protocol == "tcp" && aws_vpc_security_group_egress_rule.auction[0].from_port == 8000 && aws_vpc_security_group_egress_rule.auction[0].to_port == 8000 && aws_vpc_security_group_egress_rule.auction[0].cidr_ipv4 == "192.0.2.80/32"', "Auction egress must target only the owned task SG and exact TCP endpoint.") : ""} + }\n`; + if (qualified) { + // Keep the identity projection wired to the same plan-known local as all + // credential-removal/admission consumers, never computed AppConfig data. + const geo = fs.readFileSync(path.join(root, "geo.tf"), "utf8"); + assert.equal( + [...geo.matchAll(/var\.geo_front_door\.origin_credential_header_name/g)] + .length, + 1, + "only the shared local may read the private removal name", + ); + assert.match( + geo, + /http_header_name\s*=\s*local\.geo_credential_header_name/, + ); + assert.match(geo, /name\s*=\s*local\.geo_credential_header_name/); + assert.ok(geo.includes("jsonencode(local.geo_credential_header_name)")); + for (const phase of [null, "staged", "origin-enabled", "dns-cutover"]) { + const service = phase + ? "aws_ecs_service.geo[0]" + : "aws_ecs_service.review[0]"; + const targets = phase + ? "[aws_lb_target_group.review, aws_lb_target_group.geo[0]]" + : "[aws_lb_target_group.review]"; + contract += `\nrun "settings_${phase?.replaceAll("-", "_") || "off"}" { + command = plan + variables { + ${phase ? `geo_front_door = ${geoInput(phase)}` : ""} + task_cpu = 512 + task_memory = 3072 + log_retention_in_days = 30 + retain_logs_on_destroy = true + app_shutdown_grace_seconds = 20 + container_stop_timeout_seconds = 45 + alb_idle_timeout_seconds = 120 + target_deregistration_delay_seconds = 25 + ecs_health_grace_seconds = 180 + health_interval_seconds = 20 + health_timeout_seconds = 6 + healthy_threshold = 3 + unhealthy_threshold = 4 + application_log_filter = "debug" + agent_log_level = "info" + extra_tags = { CostCenter = "fictional" } + } + ${check('aws_ecs_task_definition.review.cpu == "512" && aws_ecs_task_definition.review.memory == "3072" && alltrue([for c in local.containers : c.stopTimeout == 45]) && local.application_environment.EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS == "20" && local.application_environment.RUST_LOG == "debug" && anytrue([for e in local.containers[1].environment : e.name == "LOG_LEVEL" && e.value == "info"])', "Task settings must be passed literally to both containers.")} + ${check('aws_cloudwatch_log_group.review.retention_in_days == 30 && aws_cloudwatch_log_group.review.skip_destroy && aws_lb.review.idle_timeout == 120 && local.tags.CostCenter == "fictional" && local.tags.RunId == var.run_id', "Log lifecycle, idle timeout and nonreserved tags must be configurable.")} + ${check(`${service}.health_check_grace_period_seconds == 180 && ${service}.desired_count == 1 && ${service}.deployment_minimum_healthy_percent == 0 && ${service}.deployment_maximum_percent == 100`, "Both service histories must use shared settings without changing stop-first semantics.")} + ${check(`alltrue([for t in ${targets} : tonumber(t.deregistration_delay) == 25 && one(t.health_check).interval == 20 && one(t.health_check).timeout == 6 && one(t.health_check).healthy_threshold == 3 && one(t.health_check).unhealthy_threshold == 4 && one(t.health_check).path == "/health" && one(t.health_check).matcher == "200"])`, "Both target-group histories must use shared health settings.")} + ${check(`issensitive(output.deployment_policy) && output.deployment_policy.run_id == var.run_id && output.deployment_policy.aws_account_id == var.aws_account_id && output.deployment_policy.aws_region == var.aws_region && output.deployment_policy.geo_enabled == ${Boolean(phase)} && output.deployment_policy.origin_credential_header_name == ${phase ? '"x-fictional-origin-check"' : "null"} && !contains(keys(output.deployment_policy), "origin_credential_value")`, "Sensitive policy must expose only known intended identity and never the credential value.")} + }\n`; + } + contract += `\nrun "defaults_preserve_settings" { + command = plan + ${check('aws_ecs_task_definition.review.cpu == "1024" && aws_ecs_task_definition.review.memory == "2048" && aws_cloudwatch_log_group.review.retention_in_days == 7 && !aws_cloudwatch_log_group.review.skip_destroy && aws_lb.review.idle_timeout == 60 && tonumber(aws_lb_target_group.review.deregistration_delay) == 15 && one(aws_lb_target_group.review.health_check).interval == 15 && one(aws_lb_target_group.review.health_check).timeout == 5 && one(aws_lb_target_group.review.health_check).healthy_threshold == 2 && one(aws_lb_target_group.review.health_check).unhealthy_threshold == 2 && aws_ecs_service.review[0].health_check_grace_period_seconds == 90 && alltrue([for c in local.containers : c.stopTimeout == 30]) && local.application_environment.EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS == "10" && local.application_environment.RUST_LOG == "warn" && anytrue([for e in local.containers[1].environment : e.name == "LOG_LEVEL" && e.value == "warn"])', "Extraction defaults must preserve the public workload settings.")} + }\n`; + for (const [name, variables, failures] of [ + ["cpu_pair", "task_cpu = 256\\ntask_memory = 3072", "var.task_memory"], + ["cpu_unknown", "task_cpu = 1000", "var.task_cpu"], + [ + "shutdown", + "app_shutdown_grace_seconds = 30", + "var.app_shutdown_grace_seconds", + ], + [ + "stop_timeout", + "container_stop_timeout_seconds = 121", + "var.container_stop_timeout_seconds", + ], + [ + "idle_timeout", + "alb_idle_timeout_seconds = 0", + "var.alb_idle_timeout_seconds", + ], + [ + "deregistration", + "target_deregistration_delay_seconds = -1", + "var.target_deregistration_delay_seconds", + ], + [ + "ecs_grace", + "ecs_health_grace_seconds = -1", + "var.ecs_health_grace_seconds", + ], + [ + "health_interval", + "health_interval_seconds = 301", + "var.health_interval_seconds", + ], + [ + "health_timeout", + "health_timeout_seconds = 15", + "var.health_timeout_seconds", + ], + ["health_threshold", "healthy_threshold = 11", "var.healthy_threshold"], + [ + "unhealthy_threshold", + "unhealthy_threshold = 1", + "var.unhealthy_threshold", + ], + ["retention", "log_retention_in_days = 2", "var.log_retention_in_days"], + ["tags", 'extra_tags = { RunId = "other" }', "var.extra_tags"], + [ + "log_filter", + 'application_log_filter = ""', + "var.application_log_filter", + ], + ["agent_level", 'agent_log_level = "verbose"', "var.agent_log_level"], + ]) { + contract += `\nrun "settings_reject_${name}" { + command = plan + variables { + ${variables.replaceAll("\\n", "\n")} + } + expect_failures = [${failures}] + }\n`; + } + contract += `\nrun "geo_header_maximum" { + command = plan + variables { geo_front_door = ${geoInput("staged", { origin_credential_header_name: `x-${"a".repeat(38)}` })} } + ${check("length(output.deployment_policy.origin_credential_header_name) == 40", "ALB permits a 40-character credential header name.")} + }\n`; + contract += `\nrun "geo_reject_header_over_limit" { + command = plan + variables { geo_front_door = ${geoInput("staged", { origin_credential_header_name: `x-${"a".repeat(39)}` })} } + expect_failures = [var.geo_front_door] + }\n`; + for (const [name, egress] of [ + ["broad_cidr", { cidr_ipv4: "0.0.0.0/0", port: 8000 }], + ["subnet", { cidr_ipv4: "192.0.2.0/24", port: 8000 }], + ["invalid_ipv4", { cidr_ipv4: "999.0.2.80/32", port: 8000 }], + ["ipv6", { cidr_ipv4: "::1/128", port: 8000 }], + ["noncanonical_ipv4", { cidr_ipv4: "192.000.2.80/32", port: 8000 }], + ["fractional_port", { cidr_ipv4: "192.0.2.80/32", port: 8000.5 }], + ["zero_port", { cidr_ipv4: "192.0.2.80/32", port: 0 }], + ["large_port", { cidr_ipv4: "192.0.2.80/32", port: 65536 }], + ]) { + contract += `\nrun "auction_reject_${name}" { + command = plan + variables { auction_egress = { cidr_ipv4 = ${JSON.stringify(egress.cidr_ipv4)}, port = ${egress.port} } } + expect_failures = [var.auction_egress] + }\n`; + } + const defaultPhaseInput = geoInput().replace( + 'rollout_phase = "staged"\n', + "", + ); + contract += `\nrun "geo_phase_defaults_staged" { + command = plan + variables { geo_front_door = ${defaultPhaseInput} } + ${check('local.geo_phase == "staged" && !aws_cloudfront_distribution.geo[0].enabled && issensitive(var.geo_front_door) && issensitive(local.native_geo_profile)', "Omitted phase must stage with no traffic, and private inputs/profile must stay sensitive.")} + }\n`; + for (const preservePublisher of [false, true]) { + contract += `\nrun "explicit_off_unchanged_${preservePublisher ? "publisher" : "demo_default"}" { + command = plan + variables { ${preservePublisher ? "preserve_publisher_hostname = true" : ""} } + ${check(`var.preserve_publisher_hostname == ${preservePublisher} && length(aws_cloudfront_distribution.geo) == 0 && length(aws_ecs_service.geo) == 0 && length(aws_ecs_service.review) == 1 && aws_ecs_service.review[0].desired_count == 1 && one(aws_lb_listener_rule.publisher.action).type == "forward" && !contains(keys(local.application_environment), "TS_NATIVE_GEO_PROFILE_TOML") && local.trusted_proxy_cidrs == local.alb_subnet_cidrs`, "Explicit null must preserve the old Off topology and runtime regardless of browser identity.")} + }\n`; + } + for (const [name, table] of [ + ["too_short", "ab"], + ["too_long", "a".repeat(256)], + ["wildcard", "example-*"], + ["question_mark", "example-?"], + ["path", "example/table"], + ["unicode", "example-\u00e9"], + ]) { + const envelope = JSON.parse( + fs.readFileSync(path.join(root, "example-envelope.json"), "utf8"), + ); + envelope.data.ec.storage.table = table; + const file = `invalid-table-${name}.json`; + fs.writeFileSync(path.join(fixture, file), JSON.stringify(envelope)); + contract += `\nrun "ec_storage_reject_${name}" { + command = plan + variables { + ec_storage = { + provider = "dynamodb" + region = "eu-west-2" + table = ${JSON.stringify(table)} + namespace = "example-staging" + } + config_envelope_file = "${file}" + } + expect_failures = [var.ec_storage] + }\n`; + } + for (const [name, overrides] of [ + ["invalid_phase", { rollout_phase: "invalid" }], + ["unbounded_proxy_trust", { cloudfront_ipv4_cidrs: ["0.0.0.0/0"] }], + [ + "wildcard_credential", + { origin_credential_value: "fictional_credential_000000000000000*" }, + ], + ["reserved_header", { origin_credential_header_name: "authorization" }], + ...[ + "fictional-origin", + "x-forwarded-fictional", + "x-amz-fictional", + "x-amzn-fictional", + "x-edge-fictional", + "x-real-ip", + "x-request-id", + "x-correlation-id", + "X-Fictional-Origin", + "cloudfront-fictional", + "host", + ].map((header, index) => [ + `dedicated_header_${index}`, + { origin_credential_header_name: header }, + ]), + [ + "wrong_viewer_region", + { + viewer_certificate_arn: + "arn:aws:acm:eu-west-2:000000000000:certificate/00000000-0000-0000-0000-000000000000", + }, + ], + ]) + contract += `\nrun "geo_reject_${name}" { + command = plan + variables { geo_front_door = ${geoInput("staged", overrides)} } + expect_failures = [var.geo_front_door] + }\n`; + } + if (qualified) { + // Synthetic envelopes exercise only Terraform's structural boundary. They + // do not claim valid TS application policy or native feature support; + // the matching real CLI remains mandatory before deployment. + const baseEnvelope = JSON.parse( + fs.readFileSync(path.join(root, "example-envelope.json"), "utf8"), + ); + const applicationCases = [ + [ + "no_review_policy", + (envelope) => { + envelope.data.handlers = []; + envelope.data.response_headers = {}; + }, + ], + [ + "application_auth_regex", + (envelope) => { + envelope.data.handlers = [ + { + path: "^/application-owned-route$", + username: "fictional", + password: "handler_password", + }, + ]; + }, + ], + [ + "native_signing", + (envelope) => { + envelope.data.request_signing = { enabled: true }; + }, + ], + [ + "http_origin", + (envelope) => { + envelope.data.publisher.origin_url = + "http://origin.publisher.example.com:8080"; + }, + ], + [ + "application_owned_secret_consumers", + (envelope) => { + delete envelope.data.ec.passphrase; + delete envelope.data.publisher.proxy_secret; + envelope.data.handlers = []; + }, + ], + ]; + for (const [name, change] of applicationCases) { + const envelope = structuredClone(baseEnvelope); + change(envelope); + const file = `application-${name}.json`; + fs.writeFileSync(path.join(fixture, file), JSON.stringify(envelope)); + contract += `\nrun "application_boundary_${name}" { + command = plan + variables { config_envelope_file = "${file}" } + ${check("local.envelope_valid && aws_appconfig_hosted_configuration_version.review.content == file(var.config_envelope_file)", "Terraform must preserve CLI bytes without imposing application policy.")} + }\n`; + } + for (const [name, change] of [ + ...["provider", "region", "table", "namespace"].map((field) => [ + `storage_mismatch_${field}`, + (envelope) => { + envelope.data.ec.storage[field] = "different"; + }, + ]), + [ + "storage_missing", + (envelope) => { + delete envelope.data.ec.storage; + }, + ], + [ + "storage_extra_field", + (envelope) => { + envelope.data.ec.storage.extra = "unexpected"; + }, + ], + [ + "wrong_version", + (envelope) => { + envelope.version = 2; + }, + ], + [ + "invalid_checksum", + (envelope) => { + envelope.sha256 = "invalid"; + }, + ], + [ + "wrong_publisher_binding", + (envelope) => { + envelope.data.publisher.domain = "other.example.com"; + }, + ], + ]) { + const envelope = structuredClone(baseEnvelope); + change(envelope); + const file = `invalid-${name}.json`; + fs.writeFileSync(path.join(fixture, file), JSON.stringify(envelope)); + contract += `\nrun "envelope_reject_${name}" { + command = plan + variables { config_envelope_file = "${file}" } + expect_failures = [terraform_data.draft_gate] + }\n`; + } + } + // Exercise the committed operator-facing defaults, not a parallel list of + // fictional values that can drift away from terraform.tfvars.example. + contract += `\nrun "committed_example_cloudfront" { + command = plan + variables { + ${fs.readFileSync(path.join(root, "terraform.tfvars.example"), "utf8")} + } + ${qualified ? "" : "expect_failures = [terraform_data.draft_gate]"} + ${qualified ? check('local.geo_enabled && local.geo_phase == "dns-cutover" && aws_cloudfront_distribution.geo[0].enabled && length(aws_ecs_service.review) == 0 && length(aws_ecs_service.geo) == 1 && one(aws_route53_record.review.alias).name == aws_cloudfront_distribution.geo[0].domain_name && local.envelope_valid', "Committed example inputs must select protected CloudFront and its DNS alias, never silently default Off.") : ""} + }\n`; + fs.writeFileSync(path.join(fixture, "tests/contract.tftest.hcl"), contract); + // This source change exists ONLY in the disposable mock-provider copy. + if (qualified) + fs.writeFileSync( + path.join(fixture, "locals.tf"), + locals.replace(gate, "deployment_blockers = toset([])"), + ); + const fixtureEnv = { + TF_DATA_DIR: path.join(fixture, ".terraform"), + TF_CLI_CONFIG_FILE: cliConfig, + }; + run( + [ + "init", + "-backend=false", + "-input=false", + "-lockfile=readonly", + "-no-color", + ], + fixture, + fixtureEnv, + ); + run(["validate", "-no-color"], fixture, fixtureEnv); + run(["test", "-no-color"], fixture, fixtureEnv); + } + // Validate only allowlisted public retained sources in a fresh directory. + // No retained state, credentials or operator tfvars are read. + const retainedRoot = path.join(root, "retained"); + const retained = path.join(temporary, "retained"); + fs.mkdirSync(path.join(retained, "tests"), { recursive: true }); + const retainedSources = [ + "main.tf", + "outputs.tf", + "terraform.tf", + "variables.tf", + ]; + assert.deepEqual( + fs + .readdirSync(retainedRoot) + .filter((name) => name.endsWith(".tf") || name.endsWith(".tf.json")) + .sort(), + retainedSources, + "retained mock checks accept only reviewed public Terraform sources", + ); + const retainedSource = retainedSources + .map((file) => fs.readFileSync(path.join(retainedRoot, file), "utf8")) + .join("\n"); + assert.deepEqual( + [...retainedSource.matchAll(/^resource "([^"]+)" "([^"]+)"/gm)].map( + (match) => [match[1], match[2]], + ), + [["aws_dynamodb_table", "ec"]], + "retained state owns only the DynamoDB table, never IAM attachments or seed rows", + ); + assert.doesNotMatch( + retainedSource, + /task_role|aws_iam|terraform_remote_state/, + ); + assert.match(retainedSource, /prevent_destroy\s*=\s*true/); + for (const file of [...retainedSources, ".terraform.lock.hcl"]) + fs.copyFileSync(path.join(retainedRoot, file), path.join(retained, file)); + fs.copyFileSync( + path.join(retainedRoot, "tests/retained.tftest.hcl"), + path.join(retained, "tests/retained.tftest.hcl"), + ); + const retainedEnv = { + TF_DATA_DIR: path.join(retained, ".terraform"), + TF_CLI_CONFIG_FILE: cliConfig, + }; + run( + [ + "init", + "-backend=false", + "-input=false", + "-lockfile=readonly", + "-no-color", + ], + retained, + retainedEnv, + ); + run(["validate", "-no-color"], retained, retainedEnv); + run(["test", "-no-color"], retained, retainedEnv); + console.log( + "PASS retained table-only layout, protected lifetime and storage outputs", + ); + + // Prove Terraform's actual destroy semantics without applying anything. + // The fictional checkpoint contains only the built-in gate resource. Its + // exact production preconditions remain closed throughout this test. + const destroyFixture = path.join(temporary, "destroy-gate"); + fs.mkdirSync(destroyFixture); + const gateSource = fs.readFileSync(path.join(root, "terraform.tf"), "utf8"); + fs.writeFileSync( + path.join(destroyFixture, "main.tf"), + ` + variable "run_id" { default = "review-example" } + locals { + ${locals.match(gate)[0]} + envelope_valid = true + } + ${gateSource.slice(gateSource.indexOf('resource "terraform_data" "draft_gate"'))} + `, + ); + fs.writeFileSync( + path.join(destroyFixture, "checkpoint.tfstate"), + JSON.stringify({ + version: 4, + terraform_version: "1.16.2", + serial: 1, + lineage: "00000000-0000-0000-0000-000000000000", + outputs: {}, + resources: [ + { + mode: "managed", + type: "terraform_data", + name: "draft_gate", + provider: 'provider["terraform.io/builtin/terraform"]', + instances: [ + { + schema_version: 0, + attributes: { + id: "00000000-0000-0000-0000-000000000001", + input: { + value: { run_id: "review-example" }, + type: ["object", { run_id: "string" }], + }, + output: { + value: { run_id: "review-example" }, + type: ["object", { run_id: "string" }], + }, + triggers_replace: null, + }, + sensitive_attributes: [], + }, + ], + }, + ], + }), + ); + const destroyEnv = { TF_DATA_DIR: path.join(destroyFixture, ".terraform") }; + run( + ["init", "-backend=false", "-input=false", "-no-color"], + destroyFixture, + destroyEnv, + ); + run(["validate", "-no-color"], destroyFixture, destroyEnv); + run( + [ + "plan", + "-destroy", + "-refresh=false", + "-input=false", + "-no-color", + "-state=checkpoint.tfstate", + "-out=destroy.tfplan", + ], + destroyFixture, + destroyEnv, + ); + const destroyJson = spawnSync( + terraform, + ["show", "-json", "destroy.tfplan"], + { + cwd: destroyFixture, + env: { ...env, ...destroyEnv }, + encoding: "utf8", + timeout: 30_000, + }, + ); + assert.equal(destroyJson.status, 0, "built-in gate destroy plan must export"); + assert.deepEqual( + JSON.parse(destroyJson.stdout).resource_changes.map( + ({ address, change }) => ({ address, actions: change.actions }), + ), + [{ address: "terraform_data.draft_gate", actions: ["delete"] }], + ); + console.log( + "PASS exact closed production gate allows an isolated delete-only destroy plan, no apply", + ); + assert.equal( + fs.readFileSync(localsPath, "utf8"), + locals, + "the real qualification gate must remain unchanged", + ); + console.log( + `PASS ${resources} guarded AWS resource blocks, fail-closed mock plans and isolated task-contract mock plans`, + ); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/deploy/examples/native-aws-workload/test-workflow.mjs b/deploy/examples/native-aws-workload/test-workflow.mjs new file mode 100644 index 000000000..0f77a55ce --- /dev/null +++ b/deploy/examples/native-aws-workload/test-workflow.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const workflow = fs.readFileSync( + new URL( + '../../../.github/workflows/native-aws-workload.yml', + import.meta.url + ), + 'utf8' +) + +test('example checks have no cloud authority or deployment operation', () => { + assert.match(workflow, /pull_request:/) + assert.match(workflow, /branches: \[main\]/) + assert.match(workflow, /permissions:\n contents: read/) + assert.doesNotMatch( + workflow, + /id-token:|secrets\.|environment:|configure-aws-credentials|pull_request_target:|workflow_run:/ + ) + assert.doesNotMatch( + workflow, + /terraform (?:apply|destroy)|run\.mjs|control-plane|bootstrap\/|deploy\/native-demo|docker (?:build|push|run)/ + ) + assert.match(workflow, /AWS_EC2_METADATA_DISABLED: ['"]true['"]/) + assert.match(workflow, /init -backend=false -input=false -lockfile=readonly/) + assert.match( + workflow, + /node deploy\/examples\/native-aws-workload\/test-draft\.mjs/ + ) + assert.match( + workflow, + /git diff --exit-code -- deploy\/examples\/native-aws-workload\/\.terraform\.lock\.hcl/ + ) +}) + +test('both Terraform roots and provider locks are checked', () => { + for (const root of [ + 'deploy/examples/native-aws-workload', + 'deploy/examples/native-aws-workload/retained', + ]) { + assert.ok( + workflow.includes( + `terraform -chdir=${root} init -backend=false -input=false -lockfile=readonly` + ) + ) + assert.ok(workflow.includes(`terraform -chdir=${root} validate`)) + } + assert.match(workflow, /fmt -check -recursive/) + assert.match( + workflow, + /git diff --exit-code -- deploy\/examples\/native-aws-workload\/\.terraform\.lock\.hcl deploy\/examples\/native-aws-workload\/retained\/\.terraform\.lock\.hcl/ + ) +}) + +test('AWS envelope verification runs and lints in native Rust CI', () => { + for (const name of ['test.yml', 'format.yml']) { + const rustWorkflow = fs.readFileSync( + new URL(`../../../.github/workflows/${name}`, import.meta.url), + 'utf8' + ) + assert.match( + rustWorkflow, + /--test template_build_digest --test aws_example_config/ + ) + } +}) + +test('only immutable actions and nonpersistent checkout credentials are used', () => { + for (const match of workflow.matchAll(/\buses:\s*([^\s#]+)/g)) + assert.match(match[1], /^[A-Za-z0-9_.\/-]+@[0-9a-f]{40}$/) + assert.match(workflow, /persist-credentials: false/) + assert.match( + workflow, + /node-version: \$\{\{ steps\.versions\.outputs\.node \}\}/ + ) + assert.match( + workflow, + /terraform_version: \$\{\{ steps\.versions\.outputs\.terraform \}\}/ + ) + assert.match(workflow, /terraform_wrapper: false/) +}) diff --git a/deploy/examples/native-aws-workload/tests/draft.tftest.hcl b/deploy/examples/native-aws-workload/tests/draft.tftest.hcl new file mode 100644 index 000000000..e010cea51 --- /dev/null +++ b/deploy/examples/native-aws-workload/tests/draft.tftest.hcl @@ -0,0 +1,101 @@ +mock_provider "aws" { + override_during = plan + + mock_resource "aws_appconfig_application" { + defaults = { id = "app1234" } + } + mock_resource "aws_appconfig_environment" { + defaults = { environment_id = "env1234" } + } + mock_resource "aws_appconfig_configuration_profile" { + defaults = { configuration_profile_id = "cfg1234" } + } +} + +variables { + run_id = "review-example" + owner = "review-owner" + aws_account_id = "000000000000" + aws_region = "eu-west-2" + availability_zones = ["eu-west-2a", "eu-west-2b"] + demo_hostname = "ts-demo.example.com" + publisher_hostname = "publisher.example.com" + route53_zone_id = "Z0000000000000" + certificate_arn = "arn:aws:acm:eu-west-2:000000000000:certificate/00000000-0000-0000-0000-000000000000" + reviewer_ipv4_cidrs = ["203.0.113.0/24"] + application_image = "000000000000.dkr.ecr.eu-west-2.amazonaws.com/review-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + agent_image = "public.ecr.aws/example/agent@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + config_envelope_file = "example-envelope.json" + geo_front_door = null + ec_storage = { + provider = "dynamodb" + region = "eu-west-2" + table = "example-retained-ec" + namespace = "example-staging" + } + application_secrets = { + handler_password = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-handler-AbCdEf" + version_id = "11111111111111111111111111111111" + } + publisher_proxy_secret = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-proxy-AbCdEf" + version_id = "22222222222222222222222222222222" + } + ec_passphrase = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-ec-AbCdEf" + version_id = "33333333333333333333333333333333" + } + } + runtime_boundaries = { + execution = "arn:aws:iam::000000000000:policy/native-demo/review-example/execution-boundary" + task = "arn:aws:iam::000000000000:policy/native-demo/review-example/task-boundary" + } + agent_token = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-agent-AbCdEf" + version_id = "44444444444444444444444444444444" + } +} + +run "draft_cannot_apply_workload" { + command = plan + expect_failures = [terraform_data.draft_gate] + + assert { + condition = length(local.deployment_blockers) == 3 && contains(local.deployment_blockers, "scoped-operator-and-locked-state-qualification") + error_message = "The source-controlled qualification blockers must not become an operator tfvars override." + } + assert { + condition = local.envelope_valid + error_message = "The CLI envelope fixture must have the supported structure and selected publisher binding." + } +} + +run "public_https_retains_qualification_gate" { + command = plan + variables { + reviewer_ipv4_cidrs = ["0.0.0.0/0"] + } + expect_failures = [terraform_data.draft_gate] + + assert { + condition = local.envelope_valid + error_message = "Public ingress does not bypass the envelope or qualification boundary." + } +} + +run "reject_other_broad_ingress_ranges" { + command = plan + variables { + reviewer_ipv4_cidrs = ["10.0.0.0/8"] + } + expect_failures = [var.reviewer_ipv4_cidrs, terraform_data.draft_gate] +} + +run "reject_tag_only_image" { + command = plan + variables { + application_image = "public.ecr.aws/example/app:latest" + } + expect_failures = [var.application_image, terraform_data.draft_gate] +} diff --git a/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture b/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture new file mode 100644 index 000000000..fbb6f2cc4 --- /dev/null +++ b/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture @@ -0,0 +1,241 @@ +mock_provider "aws" { + override_during = plan + + mock_resource "aws_appconfig_application" { + override_during = plan + defaults = { id = "app1234" } + } + mock_resource "aws_appconfig_environment" { + override_during = plan + defaults = { environment_id = "env1234" } + } + mock_resource "aws_appconfig_configuration_profile" { + override_during = plan + defaults = { configuration_profile_id = "cfg1234" } + } + mock_resource "aws_appconfig_hosted_configuration_version" { + override_during = plan + defaults = { version_number = 1 } + } + mock_resource "aws_appconfig_deployment" { + override_during = plan + defaults = { deployment_number = 1 } + } + mock_resource "aws_cloudwatch_log_group" { + override_during = plan + defaults = { arn = "arn:aws:logs:eu-west-2:000000000000:log-group:/trusted-server/review/review-example" } + } +} + +variables { + run_id = "review-example" + owner = "review-owner" + aws_account_id = "000000000000" + aws_region = "eu-west-2" + availability_zones = ["eu-west-2a", "eu-west-2b"] + demo_hostname = "ts-demo.example.com" + publisher_hostname = "publisher.example.com" + route53_zone_id = "Z0000000000000" + certificate_arn = "arn:aws:acm:eu-west-2:000000000000:certificate/00000000-0000-0000-0000-000000000000" + reviewer_ipv4_cidrs = ["203.0.113.0/24"] + application_image = "000000000000.dkr.ecr.eu-west-2.amazonaws.com/review-app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + agent_image = "public.ecr.aws/example/agent@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + config_envelope_file = "example-envelope.json" + geo_front_door = null + ec_storage = { + provider = "dynamodb" + region = "eu-west-2" + table = "example-retained-ec" + namespace = "example-staging" + } + application_secrets = { + handler_password = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-handler-AbCdEf" + version_id = "11111111111111111111111111111111" + } + publisher_proxy_secret = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-proxy-AbCdEf" + version_id = "22222222222222222222222222222222" + } + ec_passphrase = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-ec-AbCdEf" + version_id = "33333333333333333333333333333333" + } + } + runtime_boundaries = { + execution = "arn:aws:iam::000000000000:policy/native-demo/review-example/execution-boundary" + task = "arn:aws:iam::000000000000:policy/native-demo/review-example/task-boundary" + } + agent_token = { + arn = "arn:aws:secretsmanager:eu-west-2:000000000000:secret:review-agent-AbCdEf" + version_id = "44444444444444444444444444444444" + } +} + +run "task_contract" { + command = plan + + assert { + condition = local.tags.Lifecycle == "manual" && output.draft_status.manual_shutdown_required && terraform_data.draft_gate.input == { run_id = var.run_id } + error_message = "Keep the manual tag and shutdown declaration stable, with only run_id in the gate input." + } + + assert { + condition = var.auction_egress == null && length(aws_vpc_security_group_egress_rule.auction) == 0 + error_message = "Absent auction egress must preserve the existing network rules." + } + + assert { + condition = ( + local.containers[0].readonlyRootFilesystem && + local.containers[0].user == "10001:10001" && + local.containers[0].mountPoints[0].containerPath == "/run/trusted-server" && + local.containers[1].readonlyRootFilesystem && + local.containers[1].secrets[0].valueFrom == local.containers[0].secrets[0].valueFrom + ) + error_message = "Task bootstrap must be read-only/non-root with the reviewed writable path and matching Agent token reference." + } + assert { + condition = ( + !contains([for env in local.containers[1].environment : env.name], "BACKUP_DIRECTORY") && + !contains([for env in local.containers[1].environment : env.name], "MANIFEST") && + local.containers[0].dependsOn[0].condition == "START" && + length(local.containers[1].portMappings) == 0 + ) + error_message = "Agent must have no disk fallback, fabricated health dependency or exposed port." + } + assert { + condition = alltrue([for container in local.containers : + length(container.linuxParameters.capabilities.add) == 0 && + length(container.systemControls) == 0 && length(container.volumesFrom) == 0 + ]) && length(local.containers[1].mountPoints) == 0 + error_message = "Render ECS empty arrays explicitly so a refreshed task definition does not restart for a phase-only change." + } + assert { + condition = length(aws_ecs_service.review) == 1 && aws_ecs_service.review[0].desired_count == 1 && aws_ecs_service.review[0].deployment_maximum_percent == 100 && aws_ecs_service.review[0].deployment_minimum_healthy_percent == 0 + error_message = "The review workload must retain the one-task, stop-first scheduler policy; this does not prove process-drain fencing." + } + assert { + condition = local.application_environment.TS_REVIEW_CONFIG_TRANSPORT_SHA256 == sha256(local.envelope_text) && contains([for env in jsondecode(aws_ecs_task_definition.review.container_definitions)[0].environment : env.name], "TS_REVIEW_APPCONFIG_DEPLOYMENT") + error_message = "The rendered task definition must carry config release identity so a changed config cannot silently reuse an immutable snapshot." + } + assert { + condition = aws_iam_role.execution.permissions_boundary == var.runtime_boundaries.execution && aws_iam_role.task.permissions_boundary == var.runtime_boundaries.task + error_message = "Workload roles must retain the exact approved bootstrap permission ceilings." + } +} + +run "scoped_dynamodb_task_policy" { + command = plan + + assert { + condition = jsondecode(output.ec_task_policy_json) == jsondecode(<<-JSON + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ValidateExactEcTable", + "Effect": "Allow", + "Action": ["dynamodb:DescribeTable"], + "Resource": "arn:aws:dynamodb:eu-west-2:000000000000:table/example-retained-ec" + }, + { + "Sid": "IdentityRowsOnly", + "Effect": "Allow", + "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"], + "Resource": "arn:aws:dynamodb:eu-west-2:000000000000:table/example-retained-ec", + "Condition": { + "ForAllValues:StringEquals": {"dynamodb:LeadingKeys": ["ec:example-staging"]}, + "Null": {"dynamodb:LeadingKeys": "false"} + } + }, + { + "Sid": "AllocateGenerationsOnly", + "Effect": "Allow", + "Action": ["dynamodb:UpdateItem"], + "Resource": "arn:aws:dynamodb:eu-west-2:000000000000:table/example-retained-ec", + "Condition": { + "ForAllValues:StringEquals": {"dynamodb:LeadingKeys": ["ec-allocator:example-staging"]}, + "Null": {"dynamodb:LeadingKeys": "false"} + } + } + ] + } + JSON + ) + error_message = "The exported boundary document must be plan-known and restrict each DynamoDB action to its exact table and partition." + } + + assert { + condition = jsonencode([ + for statement in jsondecode(aws_iam_role_policy.task.policy).Statement : statement + if startswith(statement.Action[0], "dynamodb:") + ]) == jsonencode(jsondecode(output.ec_task_policy_json).Statement) && !anytrue([ + for statement in jsondecode(aws_iam_role_policy.execution.policy).Statement : + anytrue([for action in statement.Action : startswith(action, "dynamodb:")]) + ]) + error_message = "Only the task role may receive exactly the exported DynamoDB allows, never the execution role." + } +} + +run "customer_managed_keys" { + command = plan + variables { + secrets_kms_key_arns = ["arn:aws:kms:eu-west-2:000000000000:key/00000000-0000-0000-0000-000000000000"] + } + assert { + condition = anytrue([for statement in jsondecode(aws_iam_role_policy.task.policy).Statement : contains(statement.Action, "kms:Decrypt")]) && anytrue([for statement in jsondecode(aws_iam_role_policy.execution.policy).Statement : contains(statement.Action, "kms:Decrypt")]) + error_message = "Supplied customer-managed keys must be usable through both runtime and execution policies." + } +} + +run "public_https_network_contract" { + command = plan + variables { + reviewer_ipv4_cidrs = ["0.0.0.0/0"] + } + override_resource { + target = aws_security_group.alb + override_during = plan + values = { id = "sg-0123456789abcdef0" } + } + assert { + condition = aws_vpc_security_group_ingress_rule.reviewers["0.0.0.0/0"].from_port == 443 && aws_vpc_security_group_ingress_rule.reviewers["0.0.0.0/0"].to_port == 443 && aws_vpc_security_group_ingress_rule.reviewers["0.0.0.0/0"].ip_protocol == "tcp" + error_message = "Public ALB ingress must expose only HTTPS; application authentication belongs to TS." + } + assert { + condition = aws_vpc_security_group_ingress_rule.alb_to_task.from_port == 8787 && aws_vpc_security_group_ingress_rule.alb_to_task.referenced_security_group_id == aws_security_group.alb.id && length(local.containers[1].portMappings) == 0 + error_message = "Public HTTPS must not expose the task or Agent ports." + } +} + +run "optional_noindex_configuration_passes_through" { + command = plan + variables { + reviewer_ipv4_cidrs = ["0.0.0.0/0"] + config_envelope_file = "example-noindex-envelope.json" + } + assert { + condition = local.envelope_valid && aws_appconfig_hosted_configuration_version.review.content == file(var.config_envelope_file) + error_message = "Optional application indexing/authentication policy must pass through as exact CLI bytes." + } + assert { + condition = length(aws_lb_listener_rule.publisher.condition) == 1 && anytrue([for rule_condition in aws_lb_listener_rule.publisher.condition : try(toset(one(rule_condition.host_header).values) == toset([var.publisher_hostname, var.demo_hostname]), false)]) + error_message = "Both direct demo browsing and the publisher proxy must work without a client-supplied X-Forwarded-Host." + } + assert { + condition = one(one(aws_lb_listener.https.default_action).fixed_response).status_code == "404" + error_message = "Unconfigured Host names must still fail closed." + } +} + +run "public_registry_no_private_ecr_policy" { + command = plan + variables { + application_image = "public.ecr.aws/example/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + assert { + condition = length(local.private_ecr_arns) == 0 && !anytrue([for statement in jsondecode(aws_iam_role_policy.execution.policy).Statement : contains(statement.Action, "ecr:GetAuthorizationToken")]) + error_message = "Public-only images must not grant unused private ECR authorization." + } +} diff --git a/deploy/examples/native-aws-workload/variables.tf b/deploy/examples/native-aws-workload/variables.tf new file mode 100644 index 000000000..4bbb22eb0 --- /dev/null +++ b/deploy/examples/native-aws-workload/variables.tf @@ -0,0 +1,402 @@ +variable "run_id" { + description = "Stable workload name used by the persistent Terraform state." + type = string + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,23}$", var.run_id)) + error_message = "run_id must be 3-24 lowercase letters/digits/hyphens, starting with a letter." + } +} + +variable "owner" { + description = "Named owner responsible for cost, manual shutdown and retained resources." + type = string + validation { + condition = length(trimspace(var.owner)) > 0 + error_message = "owner must not be empty." + } +} + +variable "aws_account_id" { + description = "Explicit authorized AWS account; provider refuses another account." + type = string + validation { + condition = can(regex("^[0-9]{12}$", var.aws_account_id)) + error_message = "aws_account_id must contain 12 digits." + } +} + +variable "aws_region" { + description = "One approved commercial AWS region. No region is inferred from credentials." + type = string + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.aws_region)) + error_message = "Supply an explicit commercial AWS region." + } +} + +variable "availability_zones" { + description = "Two approved AZs for the regional ALB; the initial task runs in the first." + type = list(string) + validation { + condition = length(var.availability_zones) == 2 && length(distinct(var.availability_zones)) == 2 && alltrue([for zone in var.availability_zones : startswith(zone, var.aws_region)]) + error_message = "Supply two distinct Availability Zones in aws_region." + } +} + +variable "demo_hostname" { + description = "Owned demo HTTPS hostname whose certificate is used for proxy-to-ALB TLS." + type = string + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$", var.demo_hostname)) && var.demo_hostname != var.publisher_hostname + error_message = "demo_hostname must be a lowercase DNS hostname distinct from the publisher hostname, not a URL." + } +} + +variable "publisher_hostname" { + description = "Exact approved publisher Host retained by ts dev proxy. No production DNS change." + type = string + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$", var.publisher_hostname)) + error_message = "publisher_hostname must be a lowercase DNS hostname, not a URL." + } +} + +variable "preserve_publisher_hostname" { + description = "CloudFront-only opt-in for a publisher-origin browser proxy: fix X-Forwarded-Host to publisher_hostname instead of demo_hostname. Does not change demo TLS, ALB Host admission or production DNS." + type = bool + default = false +} + +variable "certificate_arn" { + description = "Existing reviewed ACM certificate covering demo_hostname; retained outside this workload." + type = string + validation { + condition = can(regex("^arn:aws:acm:${var.aws_region}:${var.aws_account_id}:certificate/[a-zA-Z0-9-]+$", var.certificate_arn)) + error_message = "certificate_arn must belong to the approved account and region." + } +} + +variable "route53_zone_id" { + description = "Existing approved public hosted zone for the demo alias, not the publisher zone." + type = string + validation { + condition = can(regex("^Z[A-Z0-9]+$", var.route53_zone_id)) + error_message = "route53_zone_id must be a hosted-zone ID." + } +} + +variable "reviewer_ipv4_cidrs" { + description = "HTTPS access CIDRs for direct ALB ingress. Application authentication belongs to TS; application and Agent ports remain ALB-only and loopback-only respectively." + type = set(string) + validation { + condition = length(var.reviewer_ipv4_cidrs) > 0 && length(var.reviewer_ipv4_cidrs) <= 32 && alltrue([for cidr in var.reviewer_ipv4_cidrs : can(cidrnetmask(cidr)) && (cidr == "0.0.0.0/0" || try(tonumber(split("/", cidr)[1]) >= 16, false))]) + error_message = "Supply at most 32 explicit IPv4 CIDRs: /16 or narrower, or 0.0.0.0/0 for public HTTPS protected by Basic auth." + } +} + +variable "auction_egress" { + description = "Optional reviewed auction endpoint. Adds only TCP egress to one IPv4 /32 and one port." + type = object({ + cidr_ipv4 = string + port = number + }) + default = null + validation { + condition = var.auction_egress == null ? true : try( + can(cidrnetmask(var.auction_egress.cidr_ipv4)) && + endswith(var.auction_egress.cidr_ipv4, "/32") && + cidrhost(var.auction_egress.cidr_ipv4, 0) == trimsuffix(var.auction_egress.cidr_ipv4, "/32") && + var.auction_egress.port >= 1 && var.auction_egress.port <= 65535 && + floor(var.auction_egress.port) == var.auction_egress.port, + false + ) + error_message = "auction_egress must be null or one canonical IPv4 /32 and an integer TCP port from 1 to 65535." + } +} + +variable "geo_front_door" { + description = "Explicit protected CloudFront opt-in. Null preserves Geo Off. Approved exception: origin credential name/value enter encrypted, access-scoped Terraform state. Never publish plans/state. Viewer certificate covers demo_hostname in us-east-1; regional certificate_arn covers the forwarded demo Host. CloudFront CIDRs are reviewed literal origin-facing IPv4 ranges for ordinary proxy trust only." + type = object({ + rollout_phase = optional(string, "staged") + viewer_certificate_arn = string + origin_credential_header_name = string + origin_credential_value = string + cloudfront_ipv4_cidrs = set(string) + }) + sensitive = true + validation { + condition = var.geo_front_door == null ? true : ( + contains(["staged", "origin-enabled", "dns-cutover"], var.geo_front_door.rollout_phase) && + can(regex("^arn:aws:acm:us-east-1:${var.aws_account_id}:certificate/[a-zA-Z0-9-]+$", var.geo_front_door.viewer_certificate_arn)) && + # Lowercase deployment subset of ProtectedCloudFrontAlb's dedicated + # removal policy, also excluding CloudFront-prohibited origin headers. + can(regex("^x-[a-z0-9-]{1,38}$", var.geo_front_door.origin_credential_header_name)) && + !can(regex("^(x-amz-|x-amzn-|x-edge-|x-forwarded-)", var.geo_front_door.origin_credential_header_name)) && + !contains(["x-real-ip", "x-request-id", "x-correlation-id", "host", "authorization", "cookie", "connection", "content-length", "expect", "if-match", "if-modified-since", "if-none-match", "if-range", "if-unmodified-since", "max-forwards", "proxy-authenticate", "proxy-authorization", "proxy-connection", "range", "request-range", "te", "trailer", "transfer-encoding", "upgrade", "via", "cache-control", "pragma"], var.geo_front_door.origin_credential_header_name) && + can(regex("^[A-Za-z0-9_-]{32,128}$", var.geo_front_door.origin_credential_value)) && + length(var.geo_front_door.cloudfront_ipv4_cidrs) > 0 && + alltrue([for cidr in var.geo_front_door.cloudfront_ipv4_cidrs : can(cidrnetmask(cidr)) && try(tonumber(split("/", cidr)[1]) > 0, false)]) + ) + error_message = "Supply a same-account us-east-1 viewer certificate, a dedicated private lowercase x- header name excluding forwarding/AWS/real-IP/correlation fields, a separately generated 32-128-character base64url credential, and explicit non-/0 CloudFront IPv4 CIDRs." + } +} + +variable "application_image" { + description = "Accepted linux/amd64 deployment image with both AWS providers and the reviewed runtime bootstrap wrapper. No tag-only references or deploy-time build." + type = string + validation { + condition = can(regex("^[a-zA-Z0-9./:_-]+@sha256:[0-9a-f]{64}$", var.application_image)) + error_message = "application_image must be an approved digest-pinned image reference." + } +} + +variable "agent_image" { + description = "Reviewed linux/amd64 AWS AppConfig Agent image, pinned by manifest digest." + type = string + validation { + condition = can(regex("^[a-zA-Z0-9./:_-]+@sha256:[0-9a-f]{64}$", var.agent_image)) + error_message = "agent_image must be a digest-pinned image reference." + } +} + +variable "config_envelope_file" { + description = "Reviewed reference-only TS CLI publication-envelope JSON file. Never the outer local map or a config containing resolved secret values. Content enters Terraform state." + type = string +} + +variable "ec_storage" { + description = "Exact retained ec_storage output, also published as ec.storage in the CLI envelope. The workload owns only scoped task access, never the table." + type = object({ + provider = string + region = string + table = string + namespace = string + }) + nullable = false + validation { + condition = var.ec_storage.provider == "dynamodb" && var.ec_storage.region == var.aws_region && can(regex("^[A-Za-z0-9_.-]{3,255}$", var.ec_storage.table)) + error_message = "ec_storage must select DynamoDB in the workload region and a 3-255-character table name containing only ASCII letters, digits, underscores, periods or hyphens." + } +} + +variable "application_secrets" { + description = "Existing separately seeded whole-value Secrets Manager references keyed by the actual TS config secret names. Values never enter Terraform. These supplied secrets are retained outside this workload." + type = map(object({ + arn = string + version_id = string + })) + validation { + condition = length(var.application_secrets) > 0 && alltrue([for key, ref in var.application_secrets : + can(regex("^[A-Za-z0-9_-]+$", key)) && can(regex("^arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:[A-Za-z0-9/_+=.@-]+$", ref.arn)) && can(regex("^[A-Za-z0-9-]{32,64}$", ref.version_id)) + ]) + error_message = "Each application secret requires a config-reference key, same-account/region secret ARN and immutable 32-64-character version ID." + } +} + +variable "agent_token" { + description = "Existing separately seeded Agent bearer token reference. ECS injects this same version into Agent ACCESS_TOKEN and app TS_APPCONFIG_TOKEN. No token value is accepted by Terraform." + type = object({ + arn = string + version_id = string + }) + validation { + condition = can(regex("^arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:[A-Za-z0-9/_+=.@-]+$", var.agent_token.arn)) && can(regex("^[A-Za-z0-9-]{32,64}$", var.agent_token.version_id)) + error_message = "agent_token requires a same-account/region secret ARN and immutable 32-64-character version ID." + } +} + +variable "runtime_boundaries" { + description = "Exact retained task/execution permission ceilings from the bootstrap. ARN presence is not document or runtime qualification." + type = object({ + execution = string + task = string + }) + validation { + condition = var.runtime_boundaries.execution != var.runtime_boundaries.task && alltrue([for arn in values(var.runtime_boundaries) : can(regex("^arn:aws:iam::${var.aws_account_id}:policy/[A-Za-z0-9/+=,.@_-]+$", arn))]) + error_message = "Supply two distinct same-account managed-policy ARNs from the retained bootstrap." + } +} + +variable "secrets_kms_key_arns" { + description = "Optional approved customer-managed KMS keys for supplied secrets; no key is provisioned." + type = set(string) + default = [] + validation { + condition = alltrue([for arn in var.secrets_kms_key_arns : can(regex("^arn:aws:kms:${var.aws_region}:${var.aws_account_id}:key/[a-zA-Z0-9-]+$", arn))]) + error_message = "KMS keys must belong to the approved account and region." + } +} + +variable "vpc_cidr" { + description = "Dedicated demo VPC; /16 supplies separate ALB and task subnets." + type = string + default = "10.84.0.0/16" + validation { + condition = can(cidrnetmask(var.vpc_cidr)) && endswith(var.vpc_cidr, "/16") + error_message = "vpc_cidr must be an IPv4 /16." + } +} + +variable "task_cpu" { + description = "Initial task CPU units. A starting assumption, not a measured capacity claim." + type = number + default = 1024 + validation { + condition = contains([256, 512, 1024, 2048, 4096, 8192, 16384], var.task_cpu) + error_message = "task_cpu must be a supported x86_64 Fargate CPU size." + } +} + +variable "task_memory" { + description = "Initial task memory MiB for TS plus Agent; qualify actual resource use." + type = number + default = 2048 + validation { + condition = contains(lookup({ + "256" = [512, 1024, 2048] + "512" = range(1024, 4097, 1024) + "1024" = range(2048, 8193, 1024) + "2048" = range(4096, 16385, 1024) + "4096" = range(8192, 30721, 1024) + "8192" = range(16384, 61441, 4096) + "16384" = range(32768, 122881, 8192) + }, tostring(var.task_cpu), []), var.task_memory) + error_message = "task_memory must form an AWS-supported Fargate pair with task_cpu." + } +} + +variable "log_retention_in_days" { + description = "CloudWatch workload log retention, independent of state and external audit retention." + type = number + default = 7 + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Supply a supported CloudWatch log retention value." + } +} + +variable "retain_logs_on_destroy" { + description = "Retain the workload log group when Terraform destroys the workload." + type = bool + default = false +} + +variable "app_shutdown_grace_seconds" { + description = "Application request drain grace. Leave time before ECS terminates the containers." + type = number + default = 10 + validation { + condition = floor(var.app_shutdown_grace_seconds) == var.app_shutdown_grace_seconds && var.app_shutdown_grace_seconds >= 1 && var.app_shutdown_grace_seconds < var.container_stop_timeout_seconds + error_message = "Application shutdown grace must be a positive integer below the container stop timeout." + } +} + +variable "container_stop_timeout_seconds" { + description = "ECS stop timeout for both application and Agent containers." + type = number + default = 30 + validation { + condition = floor(var.container_stop_timeout_seconds) == var.container_stop_timeout_seconds && var.container_stop_timeout_seconds >= 2 && var.container_stop_timeout_seconds <= 120 + error_message = "Fargate container stop timeout must be an integer from 2 to 120 seconds." + } +} + +variable "alb_idle_timeout_seconds" { + description = "ALB connection idle timeout." + type = number + default = 60 + validation { + condition = floor(var.alb_idle_timeout_seconds) == var.alb_idle_timeout_seconds && var.alb_idle_timeout_seconds >= 1 && var.alb_idle_timeout_seconds <= 4000 + error_message = "ALB idle timeout must be an integer from 1 to 4000 seconds." + } +} + +variable "target_deregistration_delay_seconds" { + description = "Target deregistration delay, shared by Off and Geo target groups." + type = number + default = 15 + validation { + condition = floor(var.target_deregistration_delay_seconds) == var.target_deregistration_delay_seconds && var.target_deregistration_delay_seconds >= 0 && var.target_deregistration_delay_seconds <= 3600 + error_message = "Target deregistration delay must be an integer from 0 to 3600 seconds." + } +} + +variable "ecs_health_grace_seconds" { + description = "ECS load-balancer health grace period, shared by Off and Geo services." + type = number + default = 90 + validation { + condition = floor(var.ecs_health_grace_seconds) == var.ecs_health_grace_seconds && var.ecs_health_grace_seconds >= 0 && var.ecs_health_grace_seconds <= 2147483647 + error_message = "ECS health grace must be an integer from 0 to 2147483647 seconds." + } +} + +variable "health_interval_seconds" { + description = "ALB target health check interval." + type = number + default = 15 + validation { + condition = floor(var.health_interval_seconds) == var.health_interval_seconds && var.health_interval_seconds >= 5 && var.health_interval_seconds <= 300 + error_message = "Health interval must be an integer from 5 to 300 seconds." + } +} + +variable "health_timeout_seconds" { + description = "ALB target health check timeout, shorter than its interval." + type = number + default = 5 + validation { + condition = floor(var.health_timeout_seconds) == var.health_timeout_seconds && var.health_timeout_seconds >= 2 && var.health_timeout_seconds <= 120 && var.health_timeout_seconds < var.health_interval_seconds + error_message = "Health timeout must be an integer from 2 to 120 seconds below the health interval." + } +} + +variable "healthy_threshold" { + description = "Consecutive successful ALB health checks required for readiness." + type = number + default = 2 + validation { + condition = floor(var.healthy_threshold) == var.healthy_threshold && var.healthy_threshold >= 2 && var.healthy_threshold <= 10 + error_message = "Healthy threshold must be an integer from 2 to 10." + } +} + +variable "unhealthy_threshold" { + description = "Consecutive failed ALB health checks required for removal." + type = number + default = 2 + validation { + condition = floor(var.unhealthy_threshold) == var.unhealthy_threshold && var.unhealthy_threshold >= 2 && var.unhealthy_threshold <= 10 + error_message = "Unhealthy threshold must be an integer from 2 to 10." + } +} + +variable "application_log_filter" { + description = "Literal application log level accepted by the qualified Axum producer. No environment interpolation." + type = string + default = "warn" + validation { + condition = contains(["off", "error", "warn", "info", "debug", "trace"], lower(trimspace(var.application_log_filter))) + error_message = "Application log filter must be one supported level name, not a module filter." + } +} + +variable "agent_log_level" { + description = "AWS AppConfig Agent log level." + type = string + default = "warn" + validation { + condition = contains(["trace", "debug", "info", "warn", "error", "none"], var.agent_log_level) + error_message = "Supply a supported AppConfig Agent log level." + } +} + +variable "extra_tags" { + description = "Additional tags. Ownership and identity keys remain reserved." + type = map(string) + default = {} + validation { + condition = !anytrue([for key in keys(var.extra_tags) : contains(["Application", "Purpose", "RunId", "Owner", "Lifecycle"], key) || startswith(lower(key), "aws:")]) + error_message = "Extra tags cannot override reserved ownership and identity keys or use the AWS-reserved prefix." + } +} diff --git a/deploy/runtime/native-aws/README.md b/deploy/runtime/native-aws/README.md index 8e60f5525..f0546d034 100644 --- a/deploy/runtime/native-aws/README.md +++ b/deploy/runtime/native-aws/README.md @@ -8,6 +8,12 @@ neither builds TS nor provisions infrastructure. An accepted reference must name the image whose inputs and runtime checks were actually reviewed; syntax alone is not qualification. +For the [unified AWS example](../../examples/native-aws-workload/README.md), the +provider binary must include +`aws-appconfig-agent,aws-secrets-manager,geo-cloudfront,aws-dynamodb-ec`. The +wrapper does not compile or enable those providers. EC storage comes from the +published app-config `[ec.storage]`, not the wrapper's store-binding TOML. + The image runs as UID/GID10001. `/run/trusted-server` must be an owned, writable private mount. Its declared volume does not establish ownership on an ECS task volume; that remains a deployment check. Keep the root filesystem read-only and diff --git a/docs/guide/native-ec-persistence.md b/docs/guide/native-ec-persistence.md index c7dfa94b8..d170b39c9 100644 --- a/docs/guide/native-ec-persistence.md +++ b/docs/guide/native-ec-persistence.md @@ -2,7 +2,9 @@ The native Axum adapter can use dedicated DynamoDB or celld EC persistence. Build with `aws-dynamodb-ec` or `celld-ec` and select the provider explicitly in published `[ec.storage]` configuration. This is separate from ordinary byte KV and from immutable AppConfig or secret snapshots. A local database file on an ephemeral ECS volume is not a substitute for retained identity state. -Persistence does not grant consent. Keep the shared EC consent, bot, browser-navigation and withdrawal rules intact. A request can be eligible for an auction but ineligible for an EC. Missing qualifying consent in a jurisdiction that requires it must still prevent EC issuance. Consent capture and CMP integration are separate work. +Persistence does not grant consent. Keep the shared EC consent, bot, browser-navigation and withdrawal rules intact. A request can be eligible for an auction but ineligible for an EC. Missing qualifying consent in a jurisdiction that requires it must still prevent EC issuance. The operator owns genuine CMP signal transport; an empty integrations table is not necessarily a runtime defect. + +The unified AWS example at `deploy/examples/native-aws-workload/README.md` combines the retained table and disposable Fargate workload. Its ordered setup covers input ownership, two locked state keys, retained `ec_storage` handoff, operator TOML, matching CLI envelope export and workload inputs. Operational deployment belongs in a separate, access-controlled deployer repository. Neither deployer access nor AWS credentials are needed to read the example or run its local checks. ```mermaid flowchart TD @@ -28,27 +30,20 @@ Native admin EC management and lookup remain unsupported and require separate en ## Build and select the backend -From the repository root, add the EC feature to the features your native image already needs: - -```sh -docker build --platform linux/amd64 \ - --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ - --build-arg NATIVE_FEATURES=aws-appconfig-agent,aws-secrets-manager,aws-dynamodb-ec \ - -t trusted-server-native-ec:local . -``` - -This command is an operator example, not evidence of a qualified image. Rebuild the final runtime image from the new binary and record its source, dependency and image digests. A historical AppConfig/Secrets Manager image qualification does not cover EC persistence merely because a provider was added to published configuration. +The unified AWS example requires a final Axum image built with `aws-appconfig-agent,aws-secrets-manager,geo-cloudfront,aws-dynamodb-ec`, including the TS-owned runtime wrapper in `deploy/runtime/native-aws/README.md`. Image production is separate from provisioning. Record the final image's source, dependency and image digests. A historical AppConfig/Secrets Manager image qualification does not cover EC persistence merely because a provider was added to published configuration. Nonroot/read-only execution, writable volume ownership and bounded cold AppConfig Agent startup still need qualification. Publish the non-secret selection in the existing Trusted Server app-config BlobEnvelope, through the same `ts config push`/AppConfig publication path used for other settings: ```toml [ec.storage] provider = "dynamodb" -region = "us-east-1" +region = "eu-west-2" table = "example-retained-ec" namespace = "example-staging" ``` +For the AWS example, copy the retained root's `ec_storage` output into this TOML and pass that same object to the workload root. Export with the matching source revision's TS CLI and `--no-env`. AppConfig receives the exact inner `BlobEnvelope` string, not the CLI's outer local map. Terraform checks shape, publisher and exact storage agreement but publishes the original text; it must not reserialize config or calculate the envelope checksum. + Region, table and namespace are required. Preserve the exact existing table and namespace when migrating. Use the same region as the table; unrelated `AWS_REGION` values do not select the EC region. No provider or default binding is inferred on native. An ordinary KV declaration does not enable EC persistence. Selected-provider preparation must succeed before readiness. An error never permits fallback to memory, another provider or an unbacked cookie. This is a hard cutover from native environment-only selection. `TS_EC_DYNAMODB_ENABLED=true` now fails before readiness with migration guidance, even if new config is also present. Malformed enablement also fails. Absent or `false` does not select a backend; orphaned old binding variables are ignored, not another source. Prepare and publish the new config, then remove the old enablement/region/table/namespace/endpoint launch inputs. There is no fallback reader. @@ -61,6 +56,18 @@ The provider uses the standard AWS workload credential chain. In ECS, the applic The table is a single-region base table. Do not add Global Tables or route writes to different regions and assume the same CAS guarantees. A single namespace partition and generation counter can become hot; `PAY_PER_REQUEST` is not a throughput qualification. +## Browser hostname and consent transport + +The example's `publisher.domain = "publisher.example.com"` yields an EC cookie domain of `.publisher.example.com`. Browsing at `https://ts-demo.example.com` rejects that cookie because the response URL is outside the cookie domain. EC uses `publisher.domain`, not `publisher.cookie_domain`. `X-Forwarded-Host` changes server-side request interpretation, not the browser's cookie-domain check. + +The existing Terraform constraints require distinct publisher and demo hostnames. This documentation does not change the example values or validations. The operator must supply a supported cookie-compatible hostname arrangement within those constraints, or use the existing publisher-preserving browser proxy so the visible URL remains on the publisher while its upstream connects to the demo endpoint. With protected CloudFront, `preserve_publisher_hostname` opts into the fixed publisher forwarding identity for that proxy mode. It does not itself change the browser URL or make demo-host cookies valid. + +The runtime already extracts and enforces standard consent signals, including TCF, GPP, US Privacy and GPC. Existing publisher CMP transport may be sufficient without a TS integration. An empty integrations table neither proves transport is missing nor proves it works. The operator must provide genuine signals and verify the initial decision, updates and withdrawal on the chosen browser path. Do not add fabricated consent, relax policy, or infer consent from protected geography. + +Existing Sourcepoint GPP capture can mirror its supported signals and send scoped updates through `POST /_ts/consent`. That is an example of an existing transport, not a generic TCF bridge or a prescription to add Sourcepoint or another CMP integration. Verify the selected CMP and jurisdiction rather than assuming all standards share that update path. + +Browser acceptance must inspect the visible URL, rejected-cookie diagnostics, stored `ts-ec` and its return on the next eligible request. Then verify genuine initial/no-consent behavior, a real CMP update reaching the server, and withdrawal producing durable tombstones and browser-cookie expiry. A `Set-Cookie` response header, a changed CMP UI or synthetic local consent alone is insufficient. See the browser hostname and consent qualification checklist in `deploy/examples/native-aws-workload/README.md`. + ## Celld conditional records Build the native adapter with `celld-ec` and publish an explicit binding: @@ -102,7 +109,7 @@ Missing prerequisites fail instead of silently skipping this selected check. Fix ## Table and namespace ownership -The separate examples unit provides `deploy/examples/native-ec-dynamodb/`. This self-contained Terraform example creates a retained table and attaches exact-table permissions to a supplied task role. It does not create or deploy an ECS service. +The unified example's `deploy/examples/native-aws-workload/retained/` root creates the retained table and exports `ec_storage`. It neither depends on nor attaches policies to a task role. The parent workload root creates its own role and attaches the exact-table, partition-scoped policy. Removing the workload must not remove the table or leave a retained-state reference to the deleted task role. | Item | Required layout or owner | | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | @@ -138,22 +145,24 @@ The runtime needs the following permissions on the exact table ARN: No task permission is needed for `Scan`, `CreateTable`, `DescribeTimeToLive`, `UpdateTimeToLive`, index creation or table deletion. Table creation and TTL configuration belong to the infrastructure operator. The allocator does not require `GetItem`. The example uses separate row and allocator statements, so ordinary identity deletion cannot delete the allocator through this policy. IAM partition scoping does not enforce a particular `sk` or stop a compromised task from modifying permitted rows. -The public native-demo design has two relevant policy layers. Its task inline policy grants runtime reads. The retained bootstrap task boundary has an explicit `Deny` with `NotAction` based on a read-action ceiling that excludes DynamoDB. Adding an inline allow alone still fails. +The workload's task inline policy and the externally owned permission boundary are separate layers. A boundary with an explicit `Deny`/`NotAction` read-action ceiling that excludes DynamoDB still denies the calls after an inline allow is attached. -Before any deployment, the boundary and workload owners must review both changes: +Before any deployment, the boundary and workload owners must review both layers: -1. Extend the task boundary's action ceiling with only the six required DynamoDB actions. Also add constrained boundary allows for the exact table and the two reviewed partitions. Preserve existing unrelated restrictions. An action-ceiling edit alone does not grant permission. -2. Add the equivalent exact-table, partition-scoped allow to the application's task inline policy. Review all effective denies, including organization and endpoint policies, with the actual task credentials. +1. Review the boundary's action ceiling and constrained allows for only these six DynamoDB actions on the exact table and two partitions. Preserve unrelated restrictions. An action-ceiling edit alone does not grant permission. +2. Review the workload root's exact-table, partition-scoped inline policy and all effective denies, including organization and endpoint policies, with the actual task credentials. -The example's `task_policy_json` is the constrained allow input for that review, not a replacement boundary document. Do not replace the boundary, add administrator access, remove explicit denies indiscriminately, or move the application calls to the execution role. No execution-role change is needed. Existing Secrets Manager-only KMS statements do not authorize unrelated KMS use. The example uses default service-managed DynamoDB encryption and adds no task KMS calls; a customer-managed key needs a separate review. +The workload output `ec_task_policy_json` is a plan-known scoped allow document for that review, not a replacement boundary. Do not replace the boundary, add administrator access, remove explicit denies indiscriminately, or move application calls to the execution role. No DynamoDB execution-role change is needed. Existing Secrets Manager-only KMS statements do not authorize unrelated KMS use. DynamoDB uses default encryption at rest with an AWS-owned key here, with no new task KMS permissions. A customer-managed key is deferred and needs separate review. ## Retained infrastructure, disposable workload -Keep the EC Terraform root in its own retained state, separate from the service's disposable run state. Give it a unique locked backend key and an accountable retention owner. The example enables table deletion protection and Terraform `prevent_destroy`. Neither replaces a backup, retention or erasure policy. +Keep the nested EC Terraform root in its own retained state, separate from the service's disposable workload state. Both roots use S3 lockfile locking and require the operator to explicitly supply stable installation/workload-specific backend keys. For illustration only, `example-staging/retained.tfstate` and `example-staging/workload.tfstate` are distinct keys for one installation, not defaults. The retained key must differ from the workload key, and neither may reuse another environment's state. Backend bucket/region, encryption, versioning and access controls belong to the state owner. The retained root enables table deletion protection and Terraform `prevent_destroy`. Neither replaces a backup, retention or erasure policy. Existing disposable-workload writers, manifests, inventory and cleanup code must not be assumed to know about DynamoDB. Provision this retained root through a separately reviewed operator path. Do not quietly add a retained table to automated disposable cleanup. If that ownership model changes, review creation grants, manifests, inventory, state and destruction together. -Stopping or replacing an ECS task must leave EC state available. Keep the selected backend live while issued identities and withdrawal obligations exist. Disabling it is not a safe persistence rollback; qualify a rollback that preserves those obligations before changing provider selection. Before deleting the task role, remove or transfer the retained root's policy attachment. Identity erasure, namespace retirement, backups and table removal require their own reviewed procedure. Never reset the generation allocator as part of routine row cleanup. +Stopping or replacing an ECS task must leave EC state available. Keep the selected backend live while issued identities and withdrawal obligations exist. Disabling it is not a safe persistence rollback; qualify a rollback that preserves those obligations before changing provider selection. The workload owns its role and policy attachment; retained state has no task-role dependency. Identity erasure, namespace retirement, backups and table removal require their own reviewed procedure. Never reset the generation allocator as part of routine row cleanup. + +Application configuration and secrets are immutable startup snapshots. Rolling back an ECS task does not roll back the shared latest AppConfig document. The operator must manually reconcile compatible image/config/secret versions and retained storage selection; rollback automation is not provided. The example's single stop-first task also permits update downtime. The workload still needs working regional HTTPS access to DynamoDB. This guide does not require a new NAT gateway or endpoint without checking the existing routing and endpoint policies. A read-only application root and ephemeral bindings volume can stay unchanged; neither stores the identity graph. @@ -179,4 +188,8 @@ Before authorizing a deployment, retain evidence for the rebuilt image and these - Existing route exclusions and consent rules. Health, static assets, preflight and rejected authentication must not mint identities. Missing qualifying consent must remain blocked. Synthetic local consent fixtures are not live-browser consent acceptance. - The actual final image's provider selection, non-root/read-only execution, startup, readiness, normal HTTPS trust and shutdown behavior, plus affected Fastly/WASM compatibility after shared EC changes. -After separate AWS authorization, qualify the real task role with its retained boundary and inline policy. Prove allowed operations on the reviewed table/partitions and denial elsewhere, allocator retention, logical TTL and persistence across task replacement. Separately verify a real browser request carrying genuine qualifying consent. Do not relax privacy configuration or fabricate a live signal to make that acceptance pass. +After separate AWS authorization, qualify the real task role with its retained boundary and inline policy. Prove allowed operations on the reviewed table/partitions and denial elsewhere, allocator retention, logical TTL and persistence across task replacement. Separately verify cookie acceptance and genuine initial/update/withdrawal CMP transport through the selected browser path. Do not relax privacy configuration or fabricate a live signal to make that acceptance pass. + +## Deferred production considerations + +PITR/backups and restore qualification, alarms, HA, autoscaling, private networking/NAT/endpoints, WAF, customer-managed keys, IPv6, image automation, bidder/partner integrations, signing/admin/S2S additions and rollback automation remain deferred. Backup choices must preserve allocator continuity and account for erasure obligations. Capacity, clock behavior, recovery and regional access need operator evidence before a production design can rely on them. These are considerations for separately approved work, not infrastructure or runtime additions in this example.