From 121ebcc3654eab5ccbfccb1226c587419732a1d7 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 20:34:40 -0600 Subject: [PATCH 1/2] Keep sanitized AWS workload and DynamoDB examples separate from deployment --- .github/workflows/native-aws-workload.yml | 59 ++ .../examples/native-aws-workload/.gitignore | 12 + .../native-aws-workload/.terraform.lock.hcl | 29 + deploy/examples/native-aws-workload/README.md | 77 ++ deploy/examples/native-aws-workload/config.tf | 59 ++ deploy/examples/native-aws-workload/ecs.tf | 206 ++++++ .../native-aws-workload/example-config.toml | 18 + .../native-aws-workload/example-envelope.json | 148 ++++ .../example-noindex-envelope.json | 1 + deploy/examples/native-aws-workload/geo.tf | 167 +++++ deploy/examples/native-aws-workload/iam.tf | 94 +++ deploy/examples/native-aws-workload/locals.tf | 79 ++ .../examples/native-aws-workload/network.tf | 207 ++++++ .../examples/native-aws-workload/outputs.tf | 34 + .../examples/native-aws-workload/terraform.tf | 45 ++ .../terraform.tfvars.example | 39 + .../native-aws-workload/test-draft.mjs | 679 ++++++++++++++++++ .../native-aws-workload/test-workflow.mjs | 50 ++ .../tests/draft.tftest.hcl | 94 +++ .../tests/task-contract.tftest.hcl.fixture | 181 +++++ .../examples/native-aws-workload/variables.tf | 388 ++++++++++ .../native-ec-dynamodb/.terraform.lock.hcl | 26 + deploy/examples/native-ec-dynamodb/README.md | 44 ++ deploy/examples/native-ec-dynamodb/main.tf | 77 ++ deploy/examples/native-ec-dynamodb/outputs.tf | 27 + .../examples/native-ec-dynamodb/terraform.tf | 31 + .../terraform.tfvars.example | 7 + .../examples/native-ec-dynamodb/variables.tf | 37 + 28 files changed, 2915 insertions(+) create mode 100644 .github/workflows/native-aws-workload.yml create mode 100644 deploy/examples/native-aws-workload/.gitignore create mode 100644 deploy/examples/native-aws-workload/.terraform.lock.hcl create mode 100644 deploy/examples/native-aws-workload/README.md create mode 100644 deploy/examples/native-aws-workload/config.tf create mode 100644 deploy/examples/native-aws-workload/ecs.tf create mode 100644 deploy/examples/native-aws-workload/example-config.toml create mode 100644 deploy/examples/native-aws-workload/example-envelope.json create mode 100644 deploy/examples/native-aws-workload/example-noindex-envelope.json create mode 100644 deploy/examples/native-aws-workload/geo.tf create mode 100644 deploy/examples/native-aws-workload/iam.tf create mode 100644 deploy/examples/native-aws-workload/locals.tf create mode 100644 deploy/examples/native-aws-workload/network.tf create mode 100644 deploy/examples/native-aws-workload/outputs.tf create mode 100644 deploy/examples/native-aws-workload/terraform.tf create mode 100644 deploy/examples/native-aws-workload/terraform.tfvars.example create mode 100644 deploy/examples/native-aws-workload/test-draft.mjs create mode 100644 deploy/examples/native-aws-workload/test-workflow.mjs create mode 100644 deploy/examples/native-aws-workload/tests/draft.tftest.hcl create mode 100644 deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture create mode 100644 deploy/examples/native-aws-workload/variables.tf create mode 100644 deploy/examples/native-ec-dynamodb/.terraform.lock.hcl create mode 100644 deploy/examples/native-ec-dynamodb/README.md create mode 100644 deploy/examples/native-ec-dynamodb/main.tf create mode 100644 deploy/examples/native-ec-dynamodb/outputs.tf create mode 100644 deploy/examples/native-ec-dynamodb/terraform.tf create mode 100644 deploy/examples/native-ec-dynamodb/terraform.tfvars.example create mode 100644 deploy/examples/native-ec-dynamodb/variables.tf diff --git a/.github/workflows/native-aws-workload.yml b/.github/workflows/native-aws-workload.yml new file mode 100644 index 0000000000..092bdd52e4 --- /dev/null +++ b/.github/workflows/native-aws-workload.yml @@ -0,0 +1,59 @@ +name: Native AWS workload 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 + 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 diff --git a/deploy/examples/native-aws-workload/.gitignore b/deploy/examples/native-aws-workload/.gitignore new file mode 100644 index 0000000000..eef2b17fc1 --- /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 0000000000..4f85daae2b --- /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 0000000000..d228fd3502 --- /dev/null +++ b/deploy/examples/native-aws-workload/README.md @@ -0,0 +1,77 @@ +# Native AWS workload example + +This example shows an Axum workload on AWS Fargate with AppConfig, Secrets Manager, an ALB and optional protected CloudFront Geo. It derives from the native workload Terraform and its reviewed deployer extraction. Operational deployment ownership stays in [trusted-server-deployer](https://github.com/stackpop/trusted-server-deployer), under `terraform/aws/` and `scripts/aws/`. + +This is a closed-gate example, not an approved deployment recipe. It neither implements the deployer's complete setup plan nor qualifies an AWS account, application image or public ingress. No live deployment is required for the offline checks below. + +```mermaid +flowchart TD + Browser[HTTPS browser] --> CloudFront[Optional protected CloudFront, no shared cache] + CloudFront --> ALB[HTTPS ALB with exact origin admission] + ALB --> App[Nonroot Axum task] + App --> Origin[Configured publisher origin] + App --> Agent[Authenticated loopback AppConfig Agent] + Agent --> AppConfig[CLI-produced configuration envelope] + 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. + +## What is included + +The root contains only active workload Terraform, fictional configuration fixtures and credential-free checks. It creates no operator role, protected state bucket, permission boundaries or image build/publisher. Certificates, hosted zone, digest-pinned images and immutable secret references remain operator inputs. Do not reuse another workload's state or resource identities. + +All 40 AWS resource blocks directly depend on `terraform_data.draft_gate`. Its three source-controlled blockers cover scoped operator/locked state authority, final-image/nonroot-volume behavior and live ALB forwarding/auth/health. There is no input that declares those qualifications passed. The test helper removes blockers only in disposable mock copies. Never remove them merely because tests pass. + +`terraform.tfvars.example` uses only fictional accounts, resource references, reserved documentation client CIDRs and placeholder image digests. Private VPC CIDRs are example network configuration, not an existing network. The AppConfig Agent image reference is a placeholder, not a verified official image. Never publish credentials, actual tfvars, state, saved plans, licensed data or private qualification records. + +## 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 and use the matching source revision's TS CLI for configuration. Required Axum features are `aws-appconfig-agent`, `aws-secrets-manager` and `geo-cloudfront` for the protected CloudFront path. These feature names 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`. +- Optional `TS_NATIVE_GEO_PROFILE_TOML` containing the CloudFront provider, ALB peer CIDRs and credential-removal header name. It contains no expected credential value. The runtime publishes the profile and exports `TS_NATIVE_GEO_PROFILE_FILE`; absent profile means Geo Off, while an invalid explicit profile must fail before readiness. +- `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 is ordering, not readiness. The selected image/provider must tolerate bounded cold Agent startup. No Agent disk-backup fallback is configured. +- 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 a high-availability design. + +This root does not provision DynamoDB/celld EC persistence or its runtime bindings/IAM. Do not treat a local storage fallback as durable EC support. The application release's explicit EC storage requirements must be reconciled in the operational deployer before deployment. This example also does not configure a KV Geo database/provider; managed CloudFront Geo is the path represented here. IPv6 viewers remain disabled and unqualified in this template. + +## Configuration and secrets + +The matching TS CLI validates application semantics. Export reference-only TOML with `--no-env`, preserving the CLI's exact inner `BlobEnvelope` string, not its outer local key/value map: + +```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 +``` + +Terraform checks only envelope shape and the selected publisher binding. It does not duplicate TS authentication, indexing, signing, origin-protocol or secret-consumer policy. Example envelopes are offline fixtures, not substitutes for a fresh matching CLI export. Configuration changes require new immutable startup snapshots. ECS task rollback does not restore the shared latest AppConfig document; config reconciliation remains manual. + +Application and Agent secrets use immutable Secrets Manager ARN/version references. Secret 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. The operational deployer enforces saved-state/header continuity; direct use of this example bypasses that runner policy. + +`preserve_publisher_hostname` defaults to `false`. With protected CloudFront enabled, set it to `true` only for an approved browser-proxy session that stays on `publisher_hostname` while connecting to `demo_hostname` with upstream host rewriting. CloudFront fixes `X-Forwarded-Host` to the configured publisher, never a viewer-selected value. The option has no effect without CloudFront and changes neither alias/certificates nor ALB Host/credential admission, Geo trust or no-shared-cache policy. Ordinary demo-host browsing should retain the default. Navigation, consent and vendor behavior still need separately approved browser acceptance. + +## Offline checks + +Use Terraform `1.16.2`, the locked AWS provider `6.64.0` and the repository's pinned Node. Run 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 +node deploy/examples/native-aws-workload/test-draft.mjs +node --test deploy/examples/native-aws-workload/test-workflow.mjs +``` + +When using an external `TF_DATA_DIR`, pass the same absolute directory to initialization and the helper. The helper copies only an explicit public-file allowlist into disposable roots, strips inherited credentials/provider configuration, uses a filesystem-only provider mirror and never reads operator overrides, real state or private tfvars. Tests exercise the closed production gate, isolated task/Geo/settings plans, both fixed browser identities across all routing phases and a fictional gate-only delete plan. They never apply cloud resources. + +The offline workflow has read-only repository permissions and no Environment, OIDC or cloud credentials. Schema/mock success is not proof of real IAM, Agent startup, Fargate volume ownership, TLS/DNS, credential stripping, Geo, browser behavior or complete cleanup. + +## Operator ownership and cleanup + +Keep any later deployment inputs and backend outside this checkout. State must be encrypted, versioned, protected and locked under a stable workload-specific key. Retire competing writers before any separately approved migration. Use the operational deployer for private plan validation and requested-versus-running task checks, not direct mutation from this example. + +The named operator owns charges, image updates, deliberate shutdown and manual reconciliation. Deletion is explicit, not expiry. Externally supplied state infrastructure, boundaries, certificates, zones and application secrets remain independently owned. Workload logs follow the selected retention/deletion setting; external audit records are not erased by teardown. No deployment or cleanup operation is authorized 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 0000000000..7db9eee534 --- /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 0000000000..1af578a82b --- /dev/null +++ b/deploy/examples/native-aws-workload/ecs.tf @@ -0,0 +1,206 @@ +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. +moved { + from = aws_ecs_service.review + to = aws_ecs_service.review[0] +} + +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 0000000000..b4a779544d --- /dev/null +++ b/deploy/examples/native-aws-workload/example-config.toml @@ -0,0 +1,18 @@ +# 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" + +# 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 0000000000..82e70dfca8 --- /dev/null +++ b/deploy/examples/native-aws-workload/example-envelope.json @@ -0,0 +1,148 @@ +{ + "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, + "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, + "max_body_bytes": 1048576 + }, + "debug": { + "ja4_endpoint_enabled": false, + "auction_html_comment": false, + "inject_adm_for_testing": false + } + }, + "generated_at": "2026-10-20T17:37:22Z", + "sha256": "5d2375814c35b803ec497d6dec382abce22dd11719021527134cf9cc09544491", + "version": 1 +} 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 0000000000..a5edae47fa --- /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,"pull_sync_concurrency":3,"cluster_trust_threshold":10,"cluster_recheck_secs":3600,"partners":[]},"integrations":{},"handlers":[{"path":"^/(_ts/)?admin","username":"demo-admin","password":"handler_password"}],"response_headers":{"X-Robots-Tag":"noindex, nofollow"},"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-10-22T16:17:18Z","sha256":"682f7847420292484b65d692d3e77a69f8aa28dc8ed8392bc3a4dc56a1371e8d","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 0000000000..62c08d6ac2 --- /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 0000000000..73c2af3da4 --- /dev/null +++ b/deploy/examples/native-aws-workload/iam.tf @@ -0,0 +1,94 @@ +locals { + 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) + }) + 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 0000000000..7b99f1208e --- /dev/null +++ b/deploy/examples/native-aws-workload/locals.tf @@ -0,0 +1,79 @@ +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 the publication shape and selected website binding only. + # 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, + 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 0000000000..b85808b778 --- /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 0000000000..a976725f0f --- /dev/null +++ b/deploy/examples/native-aws-workload/outputs.tf @@ -0,0 +1,34 @@ +# 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 "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 = ["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/terraform.tf b/deploy/examples/native-aws-workload/terraform.tf new file mode 100644 index 0000000000..183bc16d3b --- /dev/null +++ b/deploy/examples/native-aws-workload/terraform.tf @@ -0,0 +1,45 @@ +terraform { + required_version = ">= 1.16.2, < 1.17.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.64.0" + } + } + + # Provision this backend separately. Never reuse 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 and the selected publisher domain. Trusted Server validates application policy." + } + } +} 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 0000000000..3163f6d128 --- /dev/null +++ b/deploy/examples/native-aws-workload/terraform.tfvars.example @@ -0,0 +1,39 @@ +# 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" +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 0000000000..38fff8ee78 --- /dev/null +++ b/deploy/examples/native-aws-workload/test-draft.mjs @@ -0,0 +1,679 @@ +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}`, + ); + 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 "default_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`, "Absent profile must preserve the old Off topology and runtime regardless of browser identity.")} + }\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 [ + [ + "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`; + } + } + 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); + } + // 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 0000000000..d3953af118 --- /dev/null +++ b/deploy/examples/native-aws-workload/test-workflow.mjs @@ -0,0 +1,50 @@ +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('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 0000000000..55ace13b3b --- /dev/null +++ b/deploy/examples/native-aws-workload/tests/draft.tftest.hcl @@ -0,0 +1,94 @@ +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" + 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 0000000000..b543553d50 --- /dev/null +++ b/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture @@ -0,0 +1,181 @@ +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" + 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 "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 0000000000..5c46122ec6 --- /dev/null +++ b/deploy/examples/native-aws-workload/variables.tf @@ -0,0 +1,388 @@ +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) + }) + default = null + 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 "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/examples/native-ec-dynamodb/.terraform.lock.hcl b/deploy/examples/native-ec-dynamodb/.terraform.lock.hcl new file mode 100644 index 0000000000..38e6eaafe6 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/.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-ec-dynamodb/README.md b/deploy/examples/native-ec-dynamodb/README.md new file mode 100644 index 0000000000..729181aa39 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/README.md @@ -0,0 +1,44 @@ +# Retained native EC DynamoDB example + +This Terraform root creates one regional EC table and attaches a scoped inline policy to an existing ECS application task role. It does not create ECS, change a permissions boundary, grant operator permissions, or touch the execution role. + +See [the deployment guide](../../../docs/guide/native-ec-persistence.md) before using it. No AWS deployment is authorized by this example. + +## Ownership + +Keep this root in retained state, separate from disposable workload state. Supply a separately owned S3 backend bucket, a unique state key, region, encryption and access controls. S3 lockfile locking is enabled. Do not copy an existing workload's backend key. Commit the provider lock file from an approved initialization before deployment. + +The required `owner` owns the table, both reserved namespace partitions, allocator continuity, retention decisions, ongoing cost and eventual decommissioning. The task role remains owned by its workload. Removing the workload must not remove this table. Before retiring a role, remove or transfer this root's inline-policy attachment so retained state does not reference a deleted role. + +The table has `PAY_PER_REQUEST` capacity, string keys `pk` and `sk`, service TTL `ttl_epoch_s`, default service-managed encryption, deletion protection and Terraform `prevent_destroy`. It has no indexes or replicas. TTL is not an immediate deletion guarantee. The backend enforces logical expiration with `expires_at_ms`. + +Reserve both exact partition keys for one app/environment: + +- `ec:{namespace}` contains identities, tombstones and withdrawal markers. +- `ec-allocator:{namespace}` contains the internal `sk=generation` counter. It has no TTL and is not an identity row. Never delete or reset it if the namespace may be reused. + +Terraform does not seed or manage individual items. The runtime atomically allocates generations. Protect allocator continuity during backup, restore, migration and cleanup. Restoring an older allocator while serving newer rows can allow a stale generation to match again. + +## Review and local checks + +Copy `terraform.tfvars.example` to an approved, untracked input location and replace every illustrative identifier. Review the account, region, retained state key, task role, table/data lifetime and both namespace partitions. A namespace is not a secret and must not contain customer data. + +The no-network format check is: + +```sh +terraform fmt -check -recursive deploy/examples/native-ec-dynamodb +``` + +Provider initialization, planning and any application need separate authorization and approved credentials. This root pins Terraform and the AWS provider but intentionally supplies no live backend coordinates or credentials. It does not call AWS during the format check. + +## IAM integration + +`task_policy_json` contains exactly `DescribeTable`, `GetItem`, `PutItem`, `DeleteItem`, `Query` and `UpdateItem` on the table ARN. Row operations require the exact `ec:` partition. Only `UpdateItem` may access the exact allocator partition. `DescribeTable` is table-level and has no partition condition. There are no index ARNs, wildcard table grants, `Scan`, table creation or TTL-management permissions. + +The root attaches the policy to `task_role_name`; that is not enough when the existing task boundary explicitly denies DynamoDB. Have the boundary owner review both its action ceiling and its constrained allow statements, then review the task inline policy. Do not remove the boundary, broaden the execution role, or weaken the deny to get a successful test. Other policies can still deny the calls. + +`ec_storage` replaces the former `runtime_environment` output. Publish this non-secret object as `[ec.storage]` in the existing Trusted Server app-config BlobEnvelope before launching an application built with `aws-dynamodb-ec`. Use `terraform output -json ec_storage` only against the separately approved retained state; this guide does not run it. Keep the exact table and namespace unchanged. Config publication is the existing `ts config push`/AppConfig workflow, not container environment injection or another envelope format. The old enabled `TS_EC_DYNAMODB_ENABLED` launch contract now fails before readiness, even alongside new config. Remove the old selection and binding variables after preparing publication. External consumers of the removed output must migrate with this change. The ordinary AWS workload credential chain still supplies task credentials. Do not add access keys to Terraform or the task definition. + +## Retirement + +Stopping ECS leaves this table and state intact. The owner must separately approve data erasure, allocator retirement, backup disposition and table removal. Only reuse a namespace if its allocator history is preserved. A deliberate data deletion is not permission to reset generation history. `prevent_destroy` and deletion protection block ordinary table removal; they are not a backup policy and cannot prevent an authorized task from deleting identity rows. diff --git a/deploy/examples/native-ec-dynamodb/main.tf b/deploy/examples/native-ec-dynamodb/main.tf new file mode 100644 index 0000000000..0d42b4eaa5 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/main.tf @@ -0,0 +1,77 @@ +locals { + identity_partition = "ec:${var.namespace}" + allocator_partition = "ec-allocator:${var.namespace}" + + # This allow document does not override an explicit deny in a boundary, SCP + # or endpoint policy. It is also the input for the separate boundary review. + task_policy = { + Version = "2012-10-17" + Statement = [ + { + Sid = "ValidateExactEcTable" + Effect = "Allow" + Action = ["dynamodb:DescribeTable"] + Resource = aws_dynamodb_table.ec.arn + }, + { + Sid = "IdentityRowsOnly" + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"] + Resource = aws_dynamodb_table.ec.arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.identity_partition] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + }, + { + Sid = "AllocateGenerationsOnly" + Effect = "Allow" + Action = ["dynamodb:UpdateItem"] + Resource = aws_dynamodb_table.ec.arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.allocator_partition] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + } + ] + } +} + +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 + } + + server_side_encryption { + enabled = true + } + + # The allocator lives as long as this namespace can be used. Do not put this + # table in disposable service state or remove this protection to stop ECS. + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "ec" { + name = "trusted-server-native-ec" + role = var.task_role_name + policy = jsonencode(local.task_policy) +} diff --git a/deploy/examples/native-ec-dynamodb/outputs.tf b/deploy/examples/native-ec-dynamodb/outputs.tf new file mode 100644 index 0000000000..1212aa6015 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/outputs.tf @@ -0,0 +1,27 @@ +output "table_arn" { + description = "Exact table ARN for operator and retained boundary review." + value = aws_dynamodb_table.ec.arn +} + +output "task_policy_json" { + description = "Task allow document. Merge equivalent constrained allows into the reviewed boundary separately." + value = jsonencode(local.task_policy) +} + +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-ec-dynamodb/terraform.tf b/deploy/examples/native-ec-dynamodb/terraform.tf new file mode 100644 index 0000000000..fa125d57ff --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/terraform.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.16.2, < 1.17.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.64.0" + } + } + + # Supply a retained bucket, unique key and region at init. 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-ec-dynamodb/terraform.tfvars.example b/deploy/examples/native-ec-dynamodb/terraform.tfvars.example new file mode 100644 index 0000000000..7b04f82852 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/terraform.tfvars.example @@ -0,0 +1,7 @@ +# Illustrative identifiers only. Do not use a disposable workload ID here. +aws_account_id = "123456789012" +aws_region = "us-east-1" +table_name = "example-retained-ec" +namespace = "example-staging" +owner = "example-platform-team" +task_role_name = "example-application-task" diff --git a/deploy/examples/native-ec-dynamodb/variables.tf b/deploy/examples/native-ec-dynamodb/variables.tf new file mode 100644 index 0000000000..69984f7628 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/variables.tf @@ -0,0 +1,37 @@ +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." + } +} + +variable "task_role_name" { + description = "Existing ECS application task role name. Not the execution role. Boundary review is required separately." + type = string +} From db1ba337aefca72bfcff22d6dd6aea494adc805a Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 12 Nov 2026 14:57:29 -0600 Subject: [PATCH 2/2] Combine the AWS example with protected ingress and durable EC --- .github/workflows/format.yml | 8 +- .github/workflows/native-aws-workload.yml | 7 +- .github/workflows/test.yml | 4 +- .../tests/aws_example_config.rs | 159 +++++++++++++++++ deploy/examples/native-aws-workload/README.md | 168 ++++++++++++++---- deploy/examples/native-aws-workload/ecs.tf | 5 - .../native-aws-workload/example-config.toml | 7 + .../native-aws-workload/example-envelope.json | 149 +--------------- .../example-noindex-envelope.json | 2 +- deploy/examples/native-aws-workload/iam.tf | 37 +++- deploy/examples/native-aws-workload/locals.tf | 5 +- .../examples/native-aws-workload/outputs.tf | 7 +- .../retained}/.terraform.lock.hcl | 0 .../native-aws-workload/retained/main.tf | 34 ++++ .../retained}/outputs.tf | 5 - .../retained}/terraform.tf | 5 +- .../retained}/terraform.tfvars.example | 5 +- .../retained/tests/retained.tftest.hcl | 55 ++++++ .../retained}/variables.tf | 5 - .../examples/native-aws-workload/terraform.tf | 5 +- .../terraform.tfvars.example | 41 +++-- .../native-aws-workload/test-draft.mjs | 135 +++++++++++++- .../native-aws-workload/test-workflow.mjs | 32 ++++ .../tests/draft.tftest.hcl | 7 + .../tests/task-contract.tftest.hcl.fixture | 60 +++++++ .../examples/native-aws-workload/variables.tf | 16 +- deploy/examples/native-ec-dynamodb/README.md | 44 ----- deploy/examples/native-ec-dynamodb/main.tf | 77 -------- deploy/runtime/native-aws/README.md | 6 + docs/guide/native-ec-persistence.md | 55 +++--- 30 files changed, 774 insertions(+), 371 deletions(-) create mode 100644 crates/trusted-server-core/tests/aws_example_config.rs rename deploy/examples/{native-ec-dynamodb => native-aws-workload/retained}/.terraform.lock.hcl (100%) create mode 100644 deploy/examples/native-aws-workload/retained/main.tf rename deploy/examples/{native-ec-dynamodb => native-aws-workload/retained}/outputs.tf (79%) rename deploy/examples/{native-ec-dynamodb => native-aws-workload/retained}/terraform.tf (70%) rename deploy/examples/{native-ec-dynamodb => native-aws-workload/retained}/terraform.tfvars.example (64%) create mode 100644 deploy/examples/native-aws-workload/retained/tests/retained.tftest.hcl rename deploy/examples/{native-ec-dynamodb => native-aws-workload/retained}/variables.tf (85%) delete mode 100644 deploy/examples/native-ec-dynamodb/README.md delete mode 100644 deploy/examples/native-ec-dynamodb/main.tf diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index eb6566a3e6..9989bf12f6 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 index 092bdd52e4..8c19b12cae 100644 --- a/.github/workflows/native-aws-workload.yml +++ b/.github/workflows/native-aws-workload.yml @@ -1,4 +1,4 @@ -name: Native AWS workload example +name: Native AWS deployment example on: pull_request: @@ -55,5 +55,8 @@ jobs: 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 + 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 81599ac13c..0e617dab18 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 0000000000..c28df45376 --- /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/README.md b/deploy/examples/native-aws-workload/README.md index d228fd3502..dc995166ce 100644 --- a/deploy/examples/native-aws-workload/README.md +++ b/deploy/examples/native-aws-workload/README.md @@ -1,77 +1,185 @@ # Native AWS workload example -This example shows an Axum workload on AWS Fargate with AppConfig, Secrets Manager, an ALB and optional protected CloudFront Geo. It derives from the native workload Terraform and its reviewed deployer extraction. Operational deployment ownership stays in [trusted-server-deployer](https://github.com/stackpop/trusted-server-deployer), under `terraform/aws/` and `scripts/aws/`. +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 neither implements the deployer's complete setup plan nor qualifies an AWS account, application image or public ingress. No live deployment is required for the offline checks below. +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 - Browser[HTTPS browser] --> CloudFront[Optional protected CloudFront, no shared cache] + 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[CLI-produced configuration envelope] + 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. -## What is included +## Roots and input ownership -The root contains only active workload Terraform, fictional configuration fixtures and credential-free checks. It creates no operator role, protected state bucket, permission boundaries or image build/publisher. Certificates, hosted zone, digest-pinned images and immutable secret references remain operator inputs. Do not reuse another workload's state or resource identities. +| 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. | -All 40 AWS resource blocks directly depend on `terraform_data.draft_gate`. Its three source-controlled blockers cover scoped operator/locked state authority, final-image/nonroot-volume behavior and live ALB forwarding/auth/health. There is no input that declares those qualifications passed. The test helper removes blockers only in disposable mock copies. Never remove them merely because tests pass. +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. -`terraform.tfvars.example` uses only fictional accounts, resource references, reserved documentation client CIDRs and placeholder image digests. Private VPC CIDRs are example network configuration, not an existing network. The AppConfig Agent image reference is a placeholder, not a verified official image. Never publish credentials, actual tfvars, state, saved plans, licensed data or private qualification records. +| 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 | -## Runtime image contract +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. -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 and use the matching source revision's TS CLI for configuration. Required Axum features are `aws-appconfig-agent`, `aws-secrets-manager` and `geo-cloudfront` for the protected CloudFront path. These feature names are not evidence of runtime qualification. +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. -The task expects: +## Ordered configuration handoff -- 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`. -- Optional `TS_NATIVE_GEO_PROFILE_TOML` containing the CloudFront provider, ALB peer CIDRs and credential-removal header name. It contains no expected credential value. The runtime publishes the profile and exports `TS_NATIVE_GEO_PROFILE_FILE`; absent profile means Geo Off, while an invalid explicit profile must fail before readiness. -- `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 is ordering, not readiness. The selected image/provider must tolerate bounded cold Agent startup. No Agent disk-backup fallback is configured. -- 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 a high-availability design. +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" +} +``` -This root does not provision DynamoDB/celld EC persistence or its runtime bindings/IAM. Do not treat a local storage fallback as durable EC support. The application release's explicit EC storage requirements must be reconciled in the operational deployer before deployment. This example also does not configure a KV Geo database/provider; managed CloudFront Geo is the path represented here. IPv6 viewers remain disabled and unqualified in this template. +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. -## Configuration and secrets +### 3. Prepare operator-owned TOML -The matching TS CLI validates application semantics. Export reference-only TOML with `--no-env`, preserving the CLI's exact inner `BlobEnvelope` string, not its outer local key/value map: +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 ``` -Terraform checks only envelope shape and the selected publisher binding. It does not duplicate TS authentication, indexing, signing, origin-protocol or secret-consumer policy. Example envelopes are offline fixtures, not substitutes for a fresh matching CLI export. Configuration changes require new immutable startup snapshots. ECS task rollback does not restore the shared latest AppConfig document; config reconciliation remains manual. +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. -Application and Agent secrets use immutable Secrets Manager ARN/version references. Secret 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. The operational deployer enforces saved-state/header continuity; direct use of this example bypasses that runner policy. +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. -`preserve_publisher_hostname` defaults to `false`. With protected CloudFront enabled, set it to `true` only for an approved browser-proxy session that stays on `publisher_hostname` while connecting to `demo_hostname` with upstream host rewriting. CloudFront fixes `X-Forwarded-Host` to the configured publisher, never a viewer-selected value. The option has no effect without CloudFront and changes neither alias/certificates nor ALB Host/credential admission, Geo trust or no-shared-cache policy. Ordinary demo-host browsing should retain the default. Navigation, consent and vendor behavior still need separately approved browser acceptance. +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`, the locked AWS provider `6.64.0` and the repository's pinned Node. Run from the repository root: +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 -node deploy/examples/native-aws-workload/test-draft.mjs +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 ``` -When using an external `TF_DATA_DIR`, pass the same absolute directory to initialization and the helper. The helper copies only an explicit public-file allowlist into disposable roots, strips inherited credentials/provider configuration, uses a filesystem-only provider mirror and never reads operator overrides, real state or private tfvars. Tests exercise the closed production gate, isolated task/Geo/settings plans, both fixed browser identities across all routing phases and a fictional gate-only delete plan. They never apply cloud resources. +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 is not proof of real IAM, Agent startup, Fargate volume ownership, TLS/DNS, credential stripping, Geo, browser behavior or complete cleanup. +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). -## Operator ownership and cleanup +## Retirement and deferred production work -Keep any later deployment inputs and backend outside this checkout. State must be encrypted, versioned, protected and locked under a stable workload-specific key. Retire competing writers before any separately approved migration. Use the operational deployer for private plan validation and requested-versus-running task checks, not direct mutation from this example. +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. -The named operator owns charges, image updates, deliberate shutdown and manual reconciliation. Deletion is explicit, not expiry. Externally supplied state infrastructure, boundaries, certificates, zones and application secrets remain independently owned. Workload logs follow the selected retention/deletion setting; external audit records are not erased by teardown. No deployment or cleanup operation is authorized by this example. +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/ecs.tf b/deploy/examples/native-aws-workload/ecs.tf index 1af578a82b..375c2b609d 100644 --- a/deploy/examples/native-aws-workload/ecs.tf +++ b/deploy/examples/native-aws-workload/ecs.tf @@ -108,11 +108,6 @@ resource "aws_ecs_task_definition" "review" { // 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. -moved { - from = aws_ecs_service.review - to = aws_ecs_service.review[0] -} - resource "aws_ecs_service" "review" { count = local.geo_enabled ? 0 : 1 name = var.run_id diff --git a/deploy/examples/native-aws-workload/example-config.toml b/deploy/examples/native-aws-workload/example-config.toml index b4a779544d..3317d8f090 100644 --- a/deploy/examples/native-aws-workload/example-config.toml +++ b/deploy/examples/native-aws-workload/example-config.toml @@ -9,6 +9,13 @@ 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. diff --git a/deploy/examples/native-aws-workload/example-envelope.json b/deploy/examples/native-aws-workload/example-envelope.json index 82e70dfca8..86c93d3f5b 100644 --- a/deploy/examples/native-aws-workload/example-envelope.json +++ b/deploy/examples/native-aws-workload/example-envelope.json @@ -1,148 +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, - "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, - "max_body_bytes": 1048576 - }, - "debug": { - "ja4_endpoint_enabled": false, - "auction_html_comment": false, - "inject_adm_for_testing": false - } - }, - "generated_at": "2026-10-20T17:37:22Z", - "sha256": "5d2375814c35b803ec497d6dec382abce22dd11719021527134cf9cc09544491", - "version": 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 index a5edae47fa..b9f623e040 100644 --- a/deploy/examples/native-aws-workload/example-noindex-envelope.json +++ b/deploy/examples/native-aws-workload/example-noindex-envelope.json @@ -1 +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,"pull_sync_concurrency":3,"cluster_trust_threshold":10,"cluster_recheck_secs":3600,"partners":[]},"integrations":{},"handlers":[{"path":"^/(_ts/)?admin","username":"demo-admin","password":"handler_password"}],"response_headers":{"X-Robots-Tag":"noindex, nofollow"},"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-10-22T16:17:18Z","sha256":"682f7847420292484b65d692d3e77a69f8aa28dc8ed8392bc3a4dc56a1371e8d","version":1} \ No newline at end of file +{"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/iam.tf b/deploy/examples/native-aws-workload/iam.tf index 73c2af3da4..10c482dc9f 100644 --- a/deploy/examples/native-aws-workload/iam.tf +++ b/deploy/examples/native-aws-workload/iam.tf @@ -1,4 +1,39 @@ 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 = [{ @@ -88,7 +123,7 @@ resource "aws_iam_role_policy" "task" { Action = ["secretsmanager:GetSecretValue"] Resource = sort(distinct([for ref in values(var.application_secrets) : ref.arn])) } - ], local.kms_read_statement) + ], 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 index 7b99f1208e..ff2fbfba29 100644 --- a/deploy/examples/native-aws-workload/locals.tf +++ b/deploy/examples/native-aws-workload/locals.tf @@ -36,13 +36,14 @@ locals { envelope_text = file(var.config_envelope_file) envelope = try(jsondecode(local.envelope_text), null) app_config = try(local.envelope.data, {}) - # Terraform checks the publication shape and selected website binding only. + # 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, + local.app_config.publisher.domain == var.publisher_hostname && + jsonencode(local.app_config.ec.storage) == jsonencode(var.ec_storage), false ) diff --git a/deploy/examples/native-aws-workload/outputs.tf b/deploy/examples/native-aws-workload/outputs.tf index a976725f0f..f2fa2e96ca 100644 --- a/deploy/examples/native-aws-workload/outputs.tf +++ b/deploy/examples/native-aws-workload/outputs.tf @@ -11,6 +11,11 @@ output "deployment_policy" { } } +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 = { @@ -20,7 +25,7 @@ output "draft_status" { 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 = ["Supplied Secrets Manager secret versions", "ACM certificates", "Route53 hosted zone", "Published images", "Separately bootstrapped remote state/operator authority"] + 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"] } } diff --git a/deploy/examples/native-ec-dynamodb/.terraform.lock.hcl b/deploy/examples/native-aws-workload/retained/.terraform.lock.hcl similarity index 100% rename from deploy/examples/native-ec-dynamodb/.terraform.lock.hcl rename to deploy/examples/native-aws-workload/retained/.terraform.lock.hcl 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 0000000000..872e4500f1 --- /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-ec-dynamodb/outputs.tf b/deploy/examples/native-aws-workload/retained/outputs.tf similarity index 79% rename from deploy/examples/native-ec-dynamodb/outputs.tf rename to deploy/examples/native-aws-workload/retained/outputs.tf index 1212aa6015..ebb05a1564 100644 --- a/deploy/examples/native-ec-dynamodb/outputs.tf +++ b/deploy/examples/native-aws-workload/retained/outputs.tf @@ -3,11 +3,6 @@ output "table_arn" { value = aws_dynamodb_table.ec.arn } -output "task_policy_json" { - description = "Task allow document. Merge equivalent constrained allows into the reviewed boundary separately." - value = jsonencode(local.task_policy) -} - 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 = { diff --git a/deploy/examples/native-ec-dynamodb/terraform.tf b/deploy/examples/native-aws-workload/retained/terraform.tf similarity index 70% rename from deploy/examples/native-ec-dynamodb/terraform.tf rename to deploy/examples/native-aws-workload/retained/terraform.tf index fa125d57ff..e6c720f64d 100644 --- a/deploy/examples/native-ec-dynamodb/terraform.tf +++ b/deploy/examples/native-aws-workload/retained/terraform.tf @@ -8,8 +8,9 @@ terraform { } } - # Supply a retained bucket, unique key and region at init. The bucket and its - # access policy are owned separately from this example and the ECS workload. + # 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 diff --git a/deploy/examples/native-ec-dynamodb/terraform.tfvars.example b/deploy/examples/native-aws-workload/retained/terraform.tfvars.example similarity index 64% rename from deploy/examples/native-ec-dynamodb/terraform.tfvars.example rename to deploy/examples/native-aws-workload/retained/terraform.tfvars.example index 7b04f82852..1e5cff2024 100644 --- a/deploy/examples/native-ec-dynamodb/terraform.tfvars.example +++ b/deploy/examples/native-aws-workload/retained/terraform.tfvars.example @@ -1,7 +1,6 @@ # Illustrative identifiers only. Do not use a disposable workload ID here. -aws_account_id = "123456789012" -aws_region = "us-east-1" +aws_account_id = "000000000000" +aws_region = "eu-west-2" table_name = "example-retained-ec" namespace = "example-staging" owner = "example-platform-team" -task_role_name = "example-application-task" 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 0000000000..58be8756a0 --- /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-ec-dynamodb/variables.tf b/deploy/examples/native-aws-workload/retained/variables.tf similarity index 85% rename from deploy/examples/native-ec-dynamodb/variables.tf rename to deploy/examples/native-aws-workload/retained/variables.tf index 69984f7628..116101e6f4 100644 --- a/deploy/examples/native-ec-dynamodb/variables.tf +++ b/deploy/examples/native-aws-workload/retained/variables.tf @@ -30,8 +30,3 @@ variable "owner" { error_message = "An accountable retention owner is required." } } - -variable "task_role_name" { - description = "Existing ECS application task role name. Not the execution role. Boundary review is required separately." - type = string -} diff --git a/deploy/examples/native-aws-workload/terraform.tf b/deploy/examples/native-aws-workload/terraform.tf index 183bc16d3b..19b5effe0b 100644 --- a/deploy/examples/native-aws-workload/terraform.tf +++ b/deploy/examples/native-aws-workload/terraform.tf @@ -8,7 +8,8 @@ terraform { } } - # Provision this backend separately. Never reuse another workload's state. + # 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 @@ -39,7 +40,7 @@ resource "terraform_data" "draft_gate" { } precondition { condition = local.envelope_valid - error_message = "Supply a CLI BlobEnvelope with version 1, a SHA-256 checksum field and the selected publisher domain. Trusted Server validates application policy." + 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 index 3163f6d128..4f1338e7da 100644 --- a/deploy/examples/native-aws-workload/terraform.tfvars.example +++ b/deploy/examples/native-aws-workload/terraform.tfvars.example @@ -1,20 +1,35 @@ # 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" +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" +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" diff --git a/deploy/examples/native-aws-workload/test-draft.mjs b/deploy/examples/native-aws-workload/test-draft.mjs index 38fff8ee78..3e8dd7a4c3 100644 --- a/deploy/examples/native-aws-workload/test-draft.mjs +++ b/deploy/examples/native-aws-workload/test-draft.mjs @@ -75,6 +75,20 @@ try { 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"); @@ -413,10 +427,38 @@ try { ${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 "default_off_unchanged_${preservePublisher ? "publisher" : "demo_default"}" { + 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`, "Absent profile must preserve the old Off topology and runtime regardless of browser identity.")} + ${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 [ @@ -518,6 +560,24 @@ try { }\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) => { @@ -548,6 +608,16 @@ try { }\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) @@ -573,6 +643,67 @@ try { 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. diff --git a/deploy/examples/native-aws-workload/test-workflow.mjs b/deploy/examples/native-aws-workload/test-workflow.mjs index d3953af118..0f77a55ce7 100644 --- a/deploy/examples/native-aws-workload/test-workflow.mjs +++ b/deploy/examples/native-aws-workload/test-workflow.mjs @@ -34,6 +34,38 @@ test('example checks have no cloud authority or deployment operation', () => { ) }) +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}$/) diff --git a/deploy/examples/native-aws-workload/tests/draft.tftest.hcl b/deploy/examples/native-aws-workload/tests/draft.tftest.hcl index 55ace13b3b..e010cea513 100644 --- a/deploy/examples/native-aws-workload/tests/draft.tftest.hcl +++ b/deploy/examples/native-aws-workload/tests/draft.tftest.hcl @@ -26,6 +26,13 @@ variables { 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" 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 index b543553d50..fbb6f2cc42 100644 --- a/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture +++ b/deploy/examples/native-aws-workload/tests/task-contract.tftest.hcl.fixture @@ -41,6 +41,13 @@ variables { 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" @@ -118,6 +125,59 @@ run "task_contract" { } } +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 { diff --git a/deploy/examples/native-aws-workload/variables.tf b/deploy/examples/native-aws-workload/variables.tf index 5c46122ec6..4bbb22eb09 100644 --- a/deploy/examples/native-aws-workload/variables.tf +++ b/deploy/examples/native-aws-workload/variables.tf @@ -123,7 +123,6 @@ variable "geo_front_door" { origin_credential_value = string cloudfront_ipv4_cidrs = set(string) }) - default = null sensitive = true validation { condition = var.geo_front_door == null ? true : ( @@ -165,6 +164,21 @@ variable "config_envelope_file" { 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({ diff --git a/deploy/examples/native-ec-dynamodb/README.md b/deploy/examples/native-ec-dynamodb/README.md deleted file mode 100644 index 729181aa39..0000000000 --- a/deploy/examples/native-ec-dynamodb/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Retained native EC DynamoDB example - -This Terraform root creates one regional EC table and attaches a scoped inline policy to an existing ECS application task role. It does not create ECS, change a permissions boundary, grant operator permissions, or touch the execution role. - -See [the deployment guide](../../../docs/guide/native-ec-persistence.md) before using it. No AWS deployment is authorized by this example. - -## Ownership - -Keep this root in retained state, separate from disposable workload state. Supply a separately owned S3 backend bucket, a unique state key, region, encryption and access controls. S3 lockfile locking is enabled. Do not copy an existing workload's backend key. Commit the provider lock file from an approved initialization before deployment. - -The required `owner` owns the table, both reserved namespace partitions, allocator continuity, retention decisions, ongoing cost and eventual decommissioning. The task role remains owned by its workload. Removing the workload must not remove this table. Before retiring a role, remove or transfer this root's inline-policy attachment so retained state does not reference a deleted role. - -The table has `PAY_PER_REQUEST` capacity, string keys `pk` and `sk`, service TTL `ttl_epoch_s`, default service-managed encryption, deletion protection and Terraform `prevent_destroy`. It has no indexes or replicas. TTL is not an immediate deletion guarantee. The backend enforces logical expiration with `expires_at_ms`. - -Reserve both exact partition keys for one app/environment: - -- `ec:{namespace}` contains identities, tombstones and withdrawal markers. -- `ec-allocator:{namespace}` contains the internal `sk=generation` counter. It has no TTL and is not an identity row. Never delete or reset it if the namespace may be reused. - -Terraform does not seed or manage individual items. The runtime atomically allocates generations. Protect allocator continuity during backup, restore, migration and cleanup. Restoring an older allocator while serving newer rows can allow a stale generation to match again. - -## Review and local checks - -Copy `terraform.tfvars.example` to an approved, untracked input location and replace every illustrative identifier. Review the account, region, retained state key, task role, table/data lifetime and both namespace partitions. A namespace is not a secret and must not contain customer data. - -The no-network format check is: - -```sh -terraform fmt -check -recursive deploy/examples/native-ec-dynamodb -``` - -Provider initialization, planning and any application need separate authorization and approved credentials. This root pins Terraform and the AWS provider but intentionally supplies no live backend coordinates or credentials. It does not call AWS during the format check. - -## IAM integration - -`task_policy_json` contains exactly `DescribeTable`, `GetItem`, `PutItem`, `DeleteItem`, `Query` and `UpdateItem` on the table ARN. Row operations require the exact `ec:` partition. Only `UpdateItem` may access the exact allocator partition. `DescribeTable` is table-level and has no partition condition. There are no index ARNs, wildcard table grants, `Scan`, table creation or TTL-management permissions. - -The root attaches the policy to `task_role_name`; that is not enough when the existing task boundary explicitly denies DynamoDB. Have the boundary owner review both its action ceiling and its constrained allow statements, then review the task inline policy. Do not remove the boundary, broaden the execution role, or weaken the deny to get a successful test. Other policies can still deny the calls. - -`ec_storage` replaces the former `runtime_environment` output. Publish this non-secret object as `[ec.storage]` in the existing Trusted Server app-config BlobEnvelope before launching an application built with `aws-dynamodb-ec`. Use `terraform output -json ec_storage` only against the separately approved retained state; this guide does not run it. Keep the exact table and namespace unchanged. Config publication is the existing `ts config push`/AppConfig workflow, not container environment injection or another envelope format. The old enabled `TS_EC_DYNAMODB_ENABLED` launch contract now fails before readiness, even alongside new config. Remove the old selection and binding variables after preparing publication. External consumers of the removed output must migrate with this change. The ordinary AWS workload credential chain still supplies task credentials. Do not add access keys to Terraform or the task definition. - -## Retirement - -Stopping ECS leaves this table and state intact. The owner must separately approve data erasure, allocator retirement, backup disposition and table removal. Only reuse a namespace if its allocator history is preserved. A deliberate data deletion is not permission to reset generation history. `prevent_destroy` and deletion protection block ordinary table removal; they are not a backup policy and cannot prevent an authorized task from deleting identity rows. diff --git a/deploy/examples/native-ec-dynamodb/main.tf b/deploy/examples/native-ec-dynamodb/main.tf deleted file mode 100644 index 0d42b4eaa5..0000000000 --- a/deploy/examples/native-ec-dynamodb/main.tf +++ /dev/null @@ -1,77 +0,0 @@ -locals { - identity_partition = "ec:${var.namespace}" - allocator_partition = "ec-allocator:${var.namespace}" - - # This allow document does not override an explicit deny in a boundary, SCP - # or endpoint policy. It is also the input for the separate boundary review. - task_policy = { - Version = "2012-10-17" - Statement = [ - { - Sid = "ValidateExactEcTable" - Effect = "Allow" - Action = ["dynamodb:DescribeTable"] - Resource = aws_dynamodb_table.ec.arn - }, - { - Sid = "IdentityRowsOnly" - Effect = "Allow" - Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"] - Resource = aws_dynamodb_table.ec.arn - Condition = { - "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.identity_partition] } - Null = { "dynamodb:LeadingKeys" = "false" } - } - }, - { - Sid = "AllocateGenerationsOnly" - Effect = "Allow" - Action = ["dynamodb:UpdateItem"] - Resource = aws_dynamodb_table.ec.arn - Condition = { - "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.allocator_partition] } - Null = { "dynamodb:LeadingKeys" = "false" } - } - } - ] - } -} - -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 - } - - server_side_encryption { - enabled = true - } - - # The allocator lives as long as this namespace can be used. Do not put this - # table in disposable service state or remove this protection to stop ECS. - lifecycle { - prevent_destroy = true - } -} - -resource "aws_iam_role_policy" "ec" { - name = "trusted-server-native-ec" - role = var.task_role_name - policy = jsonencode(local.task_policy) -} diff --git a/deploy/runtime/native-aws/README.md b/deploy/runtime/native-aws/README.md index 8e60f55259..f0546d0340 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 c7dfa94b81..d170b39c9b 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.