diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ab2bb9f..748e135 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -47,7 +47,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2 with: node-version: "24" - stacks: "dev-baseline deploy-substrate-dev" + stacks: "dev-baseline deploy-substrate-dev terraform-substrate-dev" stack-name: "seahaven-dev-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_DEV }} @@ -56,7 +56,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2 with: node-version: "24" - stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod" + stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod terraform-substrate-prod" stack-name: "seahaven-prod-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }} diff --git a/README.md b/README.md index 5a3a0cf..23a8536 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,84 @@ created with the boundary already attached. 4. Merge; the substrate deploys via CD. Per-repo deploy roles and app stacks follow the cross-account migration playbook from there. +### Terraform deploy substrate (per account) + +`lib/terraform-substrate-stack.ts` + `lib/terraform-substrate/terraform-substrate.template.yaml` +deploy `seahaven-terraform-substrate` into each member account that hosts +Terraform-managed workloads (currently seahaven-prod and seahaven-dev; never +mgmt — mgmt stays SAM until its stacks migrate out). It contains only the +shared account-level plumbing: + +- the `app.terraform.io` OIDC identity provider (audience + `aws.workload.identity`; Retain — it is the federation anchor for every + future `hcptf-*` role), +- the `seahaven-hcptf-iam-management` guardrail policy: the boundary-gated + IAM role lifecycle (conditioned on `seahaven-lambda-execution-boundary`, + owned by the deploy-substrate stack — hence the explicit stack dependency + in `bin/app.ts`) plus the `DenyBoundaryTampering` / `DenyBoundaryPolicyEdit` + / `DenySelfMutation` backstops, mirroring `seahaven-cfn-exec-iam-management`. + `DenySelfMutation` here covers `hcptf-*`, `github-cfn-execution-role` and + `githubdeploy-*` — the Terraform path can never mutate either substrate's + principals. + +Per-workspace roles (`hcptf-` apply + `hcptf--plan`) are +deliberately NOT pre-provisioned — they are appended to the template at each +stack's migration time so an account never carries trust for workspaces that +do not deploy to it. + +**HCP Terraform layout (org-level setup, console):** one org `seahaven` +(free tier: 500 managed resources, 1 concurrent run); one HCP **project per +AWS account** (`seahaven-prod`, `seahaven-dev`); one **workspace per stack** +(`-`, one state file = one blast radius). Default execution mode +Remote. Never use HCP's "Quick setup AWS dynamic credentials" button — it +writes the single `TFC_AWS_RUN_ROLE_ARN`, which collapses the plan/apply role +split this substrate exists to enforce. + +**Migration checklist (per stack, in order):** + +1. Create the workspace in the target account's HCP project + (`-`). Apply method **Manual**; automatic speculative plans on + if VCS-connected (CLI `terraform plan` runs are inherently speculative). +2. PR to this repo appending `hcptf--plan` (read-only, + ViewOnlyAccess-class, **no** IAM writes, **no** guardrail-policy attach) + and `hcptf-` (attaches `seahaven-hcptf-iam-management` + stack-scoped + service statements) to the substrate template. Trust: this account's + `app.terraform.io` provider; `StringEquals` on + `app.terraform.io:aud` = `aws.workload.identity` and on + `app.terraform.io:sub` = + `organization:seahaven:project:seahaven-:workspace::run_phase:plan` + (or `:apply`). Exact `StringEquals` only — never `StringLike`, never a + wildcarded `run_phase` (a speculative PR plan must never hold write + credentials). IAM roles = mandatory GPT-4.1 cross-review + + `/sh-security-review` on the diff. +3. After deploy, verify: both roles exist; `hcptf-` lists + `seahaven-hcptf-iam-management` in `list-attached-role-policies`; trust + subs match the live org/project/workspace names byte-for-byte; simulate + the apply role against a `hcptf-*` ARN (expect `explicitDeny` from + `DenySelfMutation`) and against a normal stack role name (expect + `allowed`). +4. Set **workspace-level** variables `TFC_AWS_PLAN_ROLE_ARN` + + `TFC_AWS_APPLY_ROLE_ARN` (category env) to the verified role ARNs, plus + `TFC_AWS_PROVIDER_AUTH=true`. Never project-scoped variable sets — the + trust is pinned per workspace, so a shared set breaks every other + workspace. +5. Auto-apply stays OFF until the stack is sealed. + +**Rollback (proven in mgmt 2026-07-30):** delete any `hcptf-*` roles first +(they reference the provider), then the stack. The provider and guardrail +policy are Retain — after a stack delete, remove the orphaned provider with +`aws iam delete-open-id-connect-provider` and the policy with +`aws iam delete-policy` once nothing references them. Workspaces with state +must be migrated or destroyed HCP-side first; an OIDC provider deletion +strands them mid-run, it does not clean them up. + +**Verification of record for the guardrail policy** is mechanical +reconciliation — tag-preserving YAML load of the template vs +`get-policy-version` readback, sorted `json.dumps` compare per statement — +same discipline as the deploy-substrate reconciliation (2026-07-27), not +header-reading. The managed-policy document budget is 6,144 characters; +measure before appending statements. + ### CloudTrail (audit finding C-1) | Resource | Logical ID | Notes | diff --git a/bin/app.ts b/bin/app.ts index 4b65c4c..af49987 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -7,6 +7,7 @@ import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; import { DeploySubstrateStack } from "../lib/deploy-substrate-stack"; +import { TerraformSubstrateStack } from "../lib/terraform-substrate-stack"; import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; import { MemberBaselineStack } from "../lib/member-baseline-stack"; import { OrgGovernanceStack } from "../lib/org-governance-stack"; @@ -171,18 +172,51 @@ new MemberBaselineStack(app, "prod-baseline", { // seahaven-lambda-execution-boundary policy both returned NoSuchEntity in // 011934824531 AND 710827005802, so the named creates cannot collide with // out-of-band copies. -new DeploySubstrateStack(app, "deploy-substrate-prod", { +const deploySubstrateProd = new DeploySubstrateStack(app, "deploy-substrate-prod", { stackName: "seahaven-deploy-substrate", env: { account: PROD_ACCOUNT, region: "us-east-1" }, createOidcProvider: false, }); -new DeploySubstrateStack(app, "deploy-substrate-dev", { +const deploySubstrateDev = new DeploySubstrateStack(app, "deploy-substrate-dev", { stackName: "seahaven-deploy-substrate", env: { account: DEV_ACCOUNT, region: "us-east-1" }, createOidcProvider: false, }); +// ── Per-account HCP Terraform deploy substrate ─────────────────────────────── +// The Terraform analog of the GitHub Actions substrate above: app.terraform.io +// OIDC provider + the shared boundary-gated guardrail policy +// (seahaven-hcptf-iam-management) that per-workspace apply roles attach. +// Per-workspace hcptf-* roles are appended to the template at each stack's +// migration time, never here. prod/dev ONLY — mgmt stays SAM (Terraform POC +// decision 2026-07-30; the mgmt POC substrate was rolled back the same day). +// The guardrail policy names the seahaven-lambda-execution-boundary ARN only +// inside Condition strings, so CFN infers no creation edge — the explicit +// dependency below guarantees the deploy-substrate stack (which owns the +// boundary) lands first in any future account onboarding. First-create +// precondition verified 2026-07-30: no app.terraform.io provider and no +// hcptf-* roles in either account. +const terraformSubstrateProd = new TerraformSubstrateStack( + app, + "terraform-substrate-prod", + { + stackName: "seahaven-terraform-substrate", + env: { account: PROD_ACCOUNT, region: "us-east-1" }, + }, +); +terraformSubstrateProd.addStackDependency(deploySubstrateProd); + +const terraformSubstrateDev = new TerraformSubstrateStack( + app, + "terraform-substrate-dev", + { + stackName: "seahaven-terraform-substrate", + env: { account: DEV_ACCOUNT, region: "us-east-1" }, + }, +); +terraformSubstrateDev.addStackDependency(deploySubstrateDev); + // ── Shared DynamoDB CMK (INFRA-95 / M-3) ───────────────────────────────────── // Dedicated, standalone stack so the customer-managed key for sensitive // finance/PII DynamoDB tables is an independent shared dependency for the owning diff --git a/lib/terraform-substrate-stack.ts b/lib/terraform-substrate-stack.ts new file mode 100644 index 0000000..f0d131c --- /dev/null +++ b/lib/terraform-substrate-stack.ts @@ -0,0 +1,42 @@ +import * as cdk from "aws-cdk-lib"; +import * as cfninc from "aws-cdk-lib/cloudformation-include"; +import * as path from "path"; +import { Construct } from "constructs"; + +/** + * Per-account HCP Terraform deploy substrate: the shared account-level + * resources every Terraform workspace pipeline needs - + * - app.terraform.io OIDC identity provider (always created; Phase-0 + * checks confirmed no account has one), and + * - `seahaven-hcptf-iam-management`, the shared boundary-gated IAM + * guardrail policy every per-workspace APPLY role attaches. + * + * Deliberately NOT here: per-workspace hcptf- / hcptf--plan + * roles. Those are appended to the template at each stack's migration time + * (accumulator pattern, parallel to per-repo githubdeploy-* roles) so an + * account never accumulates trust for workspaces that do not deploy to it. + * + * The IAM guardrail statements mirror seahaven-cfn-exec-iam-management in + * lib/deploy-substrate/deploy-substrate.template.yaml - see the provenance + * header in lib/terraform-substrate/terraform-substrate.template.yaml for + * the reconciliation rule and the boundary-ARN coupling to the + * seahaven-deploy-substrate stack (bin/app.ts carries the explicit + * addStackDependency; the ARN reference alone creates no CFN edge). + */ +export class TerraformSubstrateStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + new cfninc.CfnInclude(this, "Substrate", { + templateFile: path.join( + __dirname, + "terraform-substrate", + "terraform-substrate.template.yaml", + ), + }); + + cdk.Tags.of(this).add("Project", "account-baseline"); + cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); + cdk.Tags.of(this).add("ManagedBy", "cdk"); + } +} diff --git a/lib/terraform-substrate/terraform-substrate.template.yaml b/lib/terraform-substrate/terraform-substrate.template.yaml new file mode 100644 index 0000000..61cbe9b --- /dev/null +++ b/lib/terraform-substrate/terraform-substrate.template.yaml @@ -0,0 +1,266 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Per-account HCP Terraform deploy substrate for Sea Haven Industries: + the app.terraform.io OIDC identity provider and the shared boundary-gated + IAM guardrail policy that every per-workspace Terraform APPLY role attaches. + Per-workspace hcptf-* roles are NOT pre-provisioned — they are appended to + this template at each stack's migration time. + +# PROVENANCE / DESIGN SOURCE +# Authored fresh 2026-07-30 (the mgmt Terraform POC's CLI-created provider and +# hcptf-* roles were rolled back the same day, so there is no deployed source +# to vendor). The IAM statement set in HcptfIamManagementPolicy MIRRORS the +# reviewed seahaven-cfn-exec-iam-management pattern in +# lib/deploy-substrate/deploy-substrate.template.yaml (boundary-gated +# CreateRole/AttachRolePolicy/PutRolePolicy/PutRolePermissionsBoundary + +# DenyBoundaryTampering / DenyBoundaryPolicyEdit / DenySelfMutation). If that +# pattern changes in either file, reconcile BOTH in the same piece of work and +# verify mechanically (tag-preserving YAML load + sorted JSON compare per +# statement), never by reading headers. +# +# COUPLING: the boundary ARN referenced in the Conditions below is +# seahaven-lambda-execution-boundary, created by the seahaven-deploy-substrate +# stack in the same account. The reference is a literal !Sub string inside +# Condition values, so CloudFormation infers NO ordering edge from it — +# bin/app.ts carries an explicit addStackDependency on the same-account +# deploy-substrate stack instead. The coupling is by NAME: if the boundary +# policy is ever renamed or replaced, every Condition below (and the +# deploy-substrate copy) must change in the same piece of work. INFRA-186 +# (per-workload boundary scoping) changes the boundary's CONTENT, not its ARN, +# and does not touch this file. +# +# SIZE BUDGET: an attached managed policy document is capped at 6,144 +# characters (whitespace excluded). The statement set below is ~2.5 KB. +# Measure before adding statements — len(json.dumps(doc,separators=(',',':'))) +# on the synthesized PolicyDocument — the same wall the role INLINE limit +# (10,240 bytes) put the first deploy-substrate deploy into on 2026-07-27. +# +# PER-WORKSPACE ROLE ACCUMULATOR +# At each stack's migration, a PR appends to this template: +# - hcptf--plan: read-only (ViewOnlyAccess-class), trust sub +# organization:seahaven:project:seahaven-:workspace::run_phase:plan +# - hcptf-: apply role attaching HcptfIamManagementPolicy plus +# stack-scoped service statements, trust sub ...run_phase:apply +# All subs are exact StringEquals (never StringLike, never a wildcarded +# run_phase — a speculative PR plan must never hold write credentials); +# audience is aws.workload.identity. IAM role additions here are a mandatory +# GPT-4.1 cross-review + /sh-security-review trigger. See the README +# "Terraform substrate" section for the full migration checklist and the +# rollback runbook. +# +# This template is deployed via lib/terraform-substrate-stack.ts +# (cloudformation-include) as stack seahaven-terraform-substrate, once per +# member account that hosts Terraform-managed workloads (currently +# seahaven-prod 011934824531 and seahaven-dev 710827005802; NEVER mgmt — +# mgmt stays SAM until its stacks migrate out). + +Resources: + + # --------------------------------------------------------------------------- + # HCP Terraform OIDC provider + # + # Always created: Phase-0 checks (2026-07-30) confirmed neither prod nor dev + # has an app.terraform.io provider (the mgmt POC's copy was deleted in the + # same-day rollback and never existed in the member accounts). An account + # holds exactly ONE provider per URL. + # --------------------------------------------------------------------------- + TerraformCloudOIDCProvider: + Type: AWS::IAM::OIDCProvider + Properties: + Url: https://app.terraform.io + ClientIdList: + # Default audience of HCP Terraform dynamic provider credentials + # (TFC_AWS_WORKLOAD_IDENTITY_AUDIENCE). Trust policies pin this via + # StringEquals on app.terraform.io:aud. + - aws.workload.identity + ThumbprintList: + # AWS ignores thumbprints for issuers signed by a trusted root CA + # (app.terraform.io qualifies) and secures trust via the CA bundle; + # the property is populated because CloudFormation requires a value. + # This is the thumbprint HashiCorp's own AWS setup documentation uses. + - 9e99a48a9960b14926bb7f3b02e22da2b0ab7280 + # Every future hcptf-* role trusts this provider. Retain so deleting the + # stack can never delete the account's Terraform federation anchor out + # from under live workspaces. + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + + # --------------------------------------------------------------------------- + # Shared boundary-gated IAM guardrail policy (attached managed policy) + # + # Attached by every per-workspace Terraform APPLY role (hcptf-); + # NEVER by plan roles (hcptf--plan are read-only and hold no IAM + # writes at all). Defined once here so all apply roles carry the identical + # reviewed escalation control instead of per-role copies that can drift. + # + # PRIMARY ESCALATION CONTROL (same design as INFRA-97 on the SAM side): + # every iam:CreateRole / AttachRolePolicy / PutRolePolicy is conditioned on + # the target role carrying seahaven-lambda-execution-boundary, so a role + # created by a Terraform apply can never exceed the boundary ceiling. The + # POC security review confirmed the unconditioned alternative is critical: + # iam:PutRolePolicy on Lambda exec roles + lambda:UpdateFunctionCode reads + # every secret in the account. + # --------------------------------------------------------------------------- + HcptfIamManagementPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + # Fixed name: future hcptf-* roles reference it by ARN, and a rename + # would detach-and-replace mid-update. Treat a rename as a coordinated + # migration, not an edit. + ManagedPolicyName: seahaven-hcptf-iam-management + Description: >- + Boundary-gated IAM role lifecycle for per-workspace Terraform apply + roles (hcptf-*), plus the explicit Deny backstops that keep the + permissions boundary from being detached or rewritten and the deploy + substrates' own principals from being mutated. Mirrors + seahaven-cfn-exec-iam-management; reconcile changes across both. + PolicyDocument: + Version: "2012-10-17" + Statement: + # Create role — MUST attach boundary + - Sid: IAMCreateRoleWithBoundary + Effect: Allow + Action: + - iam:CreateRole + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Attach managed policies — MUST have boundary already on role + - Sid: IAMAttachPolicyWithBoundary + Effect: Allow + Action: + - iam:AttachRolePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Put inline policy — MUST have boundary already on role + - Sid: IAMPutRolePolicyWithBoundary + Effect: Allow + Action: + - iam:PutRolePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Boundary management — SET only, never DELETE. For a delete, the + # iam:PermissionsBoundary condition key resolves to the boundary + # CURRENTLY on the target role, so a StringEquals grant would match + # exactly the roles the gate protects and self-defeat it (verified + # live against the mgmt SAM copy 2026-07-27). Terraform never needs + # the delete: it SETS the boundary on roles it creates, and destroy + # calls DeleteRole. + - Sid: IAMPutPermissionsBoundary + Effect: Allow + Action: + - iam:PutRolePermissionsBoundary + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Explicit Deny backstop (AWS's NoBoundaryPolicyEdit/NoBoundaryDelete + # delegation pattern). A Deny is required, not merely omitting the + # Allow — any future Allow added to an apply role silently reopens + # the escalation otherwise. + - Sid: DenyBoundaryTampering + Effect: Deny + Action: + - iam:DeleteRolePermissionsBoundary + - iam:DeleteUserPermissionsBoundary + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + - !Sub "arn:aws:iam::${AWS::AccountId}:user/*" + + # Whole seahaven-* policy family: this policy carries the Denies, so + # it is a higher-value target than the boundary it protects. Safe to + # scope broadly — no Terraform stack manages a seahaven-* managed + # policy, and apply roles hold no iam:CreatePolicy. + - Sid: DenyBoundaryPolicyEdit + Effect: Deny + Action: + - iam:CreatePolicyVersion + - iam:SetDefaultPolicyVersion + - iam:DeletePolicyVersion + - iam:DeletePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-*" + + # Self-protection for BOTH deploy substrates' principals. Without + # this the control is one API call from being undone — + # IAMRoleReadAndDelete below grants iam:DetachRolePolicy on + # Resource "*" unconditioned, so an apply role could detach this + # very policy from itself. Scope covers the Terraform substrate's + # own roles (hcptf-*) AND the GitHub Actions substrate's + # (github-cfn-execution-role, githubdeploy-*): a Terraform apply + # never legitimately manages any of them — hcptf-* roles are + # managed by THIS stack via the CDK bootstrap execution role, the + # GitHub-side roles by their own substrate/onboarding — so the Deny + # costs nothing operationally and closes the same + # UpdateAssumeRolePolicy-on-* repoint risk the SAM-side review + # flagged, for every substrate principal reachable from this path. + - Sid: DenySelfMutation + Effect: Deny + Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DeleteRolePermissionsBoundary + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:PutRolePermissionsBoundary + - iam:UpdateAssumeRolePolicy + - iam:UpdateRole + - iam:UpdateRoleDescription + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/hcptf-*" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/github-cfn-execution-role" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/githubdeploy-*" + + # Read / tag / delete role and policy — no boundary condition needed + # (delete cannot be boundary-conditioned, see IAMPutPermissionsBoundary; + # DenySelfMutation above is the backstop). Parity with the SAM copy. + - Sid: IAMRoleReadAndDelete + Effect: Allow + Action: + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:GetRole + - iam:GetRolePolicy + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies + - iam:ListRoles + - iam:TagRole + - iam:UntagRole + - iam:UpdateRole + - iam:UpdateRoleDescription + - iam:UpdateAssumeRolePolicy + - iam:GetPolicy + - iam:GetPolicyVersion + - iam:ListPolicies + - iam:ListPolicyVersions + Resource: "*" + + # PassRole — Terraform passes stack-created execution roles to the + # Lambda service. Other target services (e.g. scheduler.amazonaws.com, + # apigateway.amazonaws.com) are NOT granted here: a stack that needs + # one adds a scoped PassRole statement on its own apply role at + # migration time. + - Sid: IAMPassRole + Effect: Allow + Action: + - iam:PassRole + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PassedToService": "lambda.amazonaws.com"