feat(iac): add per-account HCP Terraform deploy substrate for prod and dev

New stack seahaven-terraform-substrate (instances terraform-substrate-prod +
terraform-substrate-dev): app.terraform.io OIDC provider and the shared
boundary-gated guardrail policy seahaven-hcptf-iam-management that
per-workspace Terraform apply roles attach at migration time. No roles are
pre-provisioned (accumulator pattern, parallel to githubdeploy-*).

Guardrail statements mirror seahaven-cfn-exec-iam-management byte-identically
except DenySelfMutation, whose scope extends to hcptf-* alongside the
GitHub-substrate principals. Explicit stack dependency on the same-account
deploy-substrate stack (boundary ARN appears only in Condition strings, so
CFN infers no edge).
This commit is contained in:
Adam Moussa 2026-07-30 16:31:34 -04:00
parent 47a6ca2939
commit ea27635ef2
No known key found for this signature in database
5 changed files with 424 additions and 4 deletions

View file

@ -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 }}

View file

@ -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-<stack>` apply + `hcptf-<stack>-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**
(`<stack>-<env>`, 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
(`<stack>-<env>`). Apply method **Manual**; automatic speculative plans on
if VCS-connected (CLI `terraform plan` runs are inherently speculative).
2. PR to this repo appending `hcptf-<stack>-plan` (read-only,
ViewOnlyAccess-class, **no** IAM writes, **no** guardrail-policy attach)
and `hcptf-<stack>` (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-<env>:workspace:<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-<stack>` 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 |

View file

@ -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

View file

@ -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-<stack> / hcptf-<stack>-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");
}
}

View file

@ -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-<stack>-plan: read-only (ViewOnlyAccess-class), trust sub
# organization:seahaven:project:seahaven-<env>:workspace:<workspace>:run_phase:plan
# - hcptf-<stack>: 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-<stack>);
# NEVER by plan roles (hcptf-<stack>-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"