Merge pull request #67 from Sea-Haven-Industries/feature/terraform-substrate
Some checks are pending
Deploy / deploy-management (push) Waiting to run
Deploy / deploy-external-dev (push) Waiting to run
Deploy / deploy-security (push) Waiting to run
Deploy / deploy-dev (push) Waiting to run
Deploy / deploy-prod (push) Waiting to run

feat(iac): add per-account HCP Terraform deploy substrate for prod and dev
This commit is contained in:
Adam Moussa 2026-07-30 17:03:24 -04:00 • committed by GitHub
commit 0e6e8ae50e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 600 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 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
with: with:
node-version: "24" node-version: "24"
stacks: "dev-baseline deploy-substrate-dev" stacks: "dev-baseline deploy-substrate-dev terraform-substrate-dev"
stack-name: "seahaven-dev-baseline" stack-name: "seahaven-dev-baseline"
secrets: secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_DEV }} 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 uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
with: with:
node-version: "24" 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" stack-name: "seahaven-prod-baseline"
secrets: secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }} deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }}

131
README.md
View file

@ -187,6 +187,137 @@ created with the boundary already attached.
4. Merge; the substrate deploys via CD. Per-repo deploy roles and app stacks 4. Merge; the substrate deploys via CD. Per-repo deploy roles and app stacks
follow the cross-account migration playbook from there. 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.
**This policy derives from `seahaven-cfn-exec-iam-management` but is
deliberately stricter — it is not a mirror.** The 2026-07-30 security review
confirmed the SAM copy's `Resource: "*"` role grants as a critical escalation
primitive (`iam:UpdateAssumeRolePolicy` on `*` repoints the AdministratorAccess
CDK bootstrap role's trust policy to an external account), and its justification
for the wildcard — SAM auto-generates execution roles at path `/` with no
settable `RolePath` — does not transfer, because Terraform's `aws_iam_role`
supports `path`. So here:
- every role **write** (create, delete, detach, `UpdateAssumeRolePolicy`,
boundary set) and `iam:PassRole` is confined to the Terraform-owned path
`role/tf-managed/*`; reads stay on `*` for data sources,
- **Terraform configs must set `path = "/tf-managed/"` on every
`aws_iam_role`** — a role created anywhere else is denied,
- `DenySelfMutation` additionally covers `cdk-hnb659fds-*`,
`OrganizationAccountAccessRole` and `seahaven-*` (detective-control roles,
which no prod/nonprod SCP shields from `iam:DeleteRole`).
Do not "reconcile" the two files by copying statements between them. The
durable org-level fix for the same class is extending the existing
`ProtectPrivilegedRoles` SCP (currently security-OU only) to prod and nonprod.
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 —
`arn:aws:iam::aws:policy/job-function/ViewOnlyAccess`, never
`ReadOnlyAccess`, which grants `secretsmanager:GetSecretValue`,
`s3:GetObject` and `kms:Decrypt` and would let any PR-triggered speculative
plan render secret values into HCP run output; **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.
**HCP-side authority is AWS authority.** AWS exposes only `aud`, `sub` and
`amr` as trust-policy condition keys for a generic OIDC provider — HCP's
immutable `terraform_workspace_id` / `terraform_project_id` claims are *not*
usable in an IAM condition (AWS's provider-specific claim validation covers
Google, GitHub, CircleCI and OCI only). The `sub` pin therefore rests on HCP
display names, so whoever can create, rename, move or delete a workspace in the
`seahaven-prod` project effectively holds prod deploy authority. Restrict that
HCP team permission to the same people, and when a workspace is retired, delete
its `hcptf-*` roles in the same change so a reused name cannot inherit them.
**Terraform state is secret-bearing.** HCP-hosted state records sensitive
attributes in full and lives outside the AWS accounts, readable by any HCP
principal with workspace read. Per the handbook's secrets-and-config rule,
secrets stay in Secrets Manager / SSM and are referenced by ARN: do not manage
secret *values* in Terraform (create the secret shell, populate out of band or
via write-only/ephemeral arguments) so no value enters state.
**Rollback (proven in mgmt 2026-07-30):** delete any `hcptf-*` roles first —
they reference the provider, and while any of them still attaches the guardrail
policy the stack delete cannot remove it. Then delete the stack. Only the
**provider** is `Retain`: it survives as an orphan and is removed with
`aws iam delete-open-id-connect-provider`. The **guardrail policy is deleted
with the stack** — do not expect it to persist, and note that every
`DenySelfMutation` / `DenyBoundaryTampering` backstop goes with it, so an
`hcptf-*` role recreated out of band afterwards is *not* gated. Workspaces
holding state must be migrated or destroyed HCP-side first; deleting the OIDC
provider strands them mid-run rather than cleaning them up.
**First-create rollback trap.** The provider is `Retain`, so if any other
resource in this stack fails on first create, CloudFormation rolls back, the
provider survives untracked, and the stack lands in `ROLLBACK_COMPLETE` — which
cannot be updated, and cannot be recreated because an account holds exactly one
provider per URL. Recovery: delete the stack, then either remove the orphaned
provider with the command above before retrying, or redeploy with
`createOidcProvider: false`. Note `cd-cdk`'s pre-flight and health check probe
only the job's single `stack-name` input (the account baseline), so a wedged
substrate stack does not show up there — check it directly.
**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) ### CloudTrail (audit finding C-1)
| Resource | Logical ID | Notes | | Resource | Logical ID | Notes |

View file

@ -7,6 +7,7 @@ import { BackupOffsiteStack } from "../lib/backup-offsite-stack";
import { BackupStack } from "../lib/backup-stack"; import { BackupStack } from "../lib/backup-stack";
import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack";
import { DeploySubstrateStack } from "../lib/deploy-substrate-stack"; import { DeploySubstrateStack } from "../lib/deploy-substrate-stack";
import { TerraformSubstrateStack } from "../lib/terraform-substrate-stack";
import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack";
import { MemberBaselineStack } from "../lib/member-baseline-stack"; import { MemberBaselineStack } from "../lib/member-baseline-stack";
import { OrgGovernanceStack } from "../lib/org-governance-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 // seahaven-lambda-execution-boundary policy both returned NoSuchEntity in
// 011934824531 AND 710827005802, so the named creates cannot collide with // 011934824531 AND 710827005802, so the named creates cannot collide with
// out-of-band copies. // out-of-band copies.
new DeploySubstrateStack(app, "deploy-substrate-prod", { const deploySubstrateProd = new DeploySubstrateStack(app, "deploy-substrate-prod", {
stackName: "seahaven-deploy-substrate", stackName: "seahaven-deploy-substrate",
env: { account: PROD_ACCOUNT, region: "us-east-1" }, env: { account: PROD_ACCOUNT, region: "us-east-1" },
createOidcProvider: false, createOidcProvider: false,
}); });
new DeploySubstrateStack(app, "deploy-substrate-dev", { const deploySubstrateDev = new DeploySubstrateStack(app, "deploy-substrate-dev", {
stackName: "seahaven-deploy-substrate", stackName: "seahaven-deploy-substrate",
env: { account: DEV_ACCOUNT, region: "us-east-1" }, env: { account: DEV_ACCOUNT, region: "us-east-1" },
createOidcProvider: false, 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) ───────────────────────────────────── // ── Shared DynamoDB CMK (INFRA-95 / M-3) ─────────────────────────────────────
// Dedicated, standalone stack so the customer-managed key for sensitive // Dedicated, standalone stack so the customer-managed key for sensitive
// finance/PII DynamoDB tables is an independent shared dependency for the owning // finance/PII DynamoDB tables is an independent shared dependency for the owning

View file

@ -0,0 +1,68 @@
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";
export interface TerraformSubstrateStackProps extends cdk.StackProps {
/**
* Create the app.terraform.io OIDC identity provider in this account.
* Defaults to true - Phase-0 checks (2026-07-30) confirmed neither prod nor
* dev has one. Set false for an account that already has the provider: an
* account holds exactly ONE provider per URL, so a duplicate create fails.
*
* This flag also makes a first-create rollback recoverable. The provider is
* Retain, so if any other resource in this stack fails on FIRST create the
* provider survives as an orphan while the stack lands in ROLLBACK_COMPLETE
* (which cannot be updated). Recovery is to delete the stack and either
* remove the orphaned provider or redeploy with this false.
*/
createOidcProvider?: boolean;
}
/**
* Per-account HCP Terraform deploy substrate: the shared account-level
* resources every Terraform workspace pipeline needs -
* - app.terraform.io OIDC identity provider (conditional, see props), 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 DERIVE FROM seahaven-cfn-exec-iam-management in
* lib/deploy-substrate/deploy-substrate.template.yaml but are deliberately
* STRICTER (role writes and PassRole confined to the tf-managed path, wider
* DenySelfMutation) - the SAM copy's Resource "*" grants were confirmed a
* critical escalation primitive by the 2026-07-30 security review, and its
* justification for them does not transfer to Terraform. See the provenance
* header in lib/terraform-substrate/terraform-substrate.template.yaml for the
* full divergence list, and for 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?: TerraformSubstrateStackProps,
) {
super(scope, id, props);
new cfninc.CfnInclude(this, "Substrate", {
templateFile: path.join(
__dirname,
"terraform-substrate",
"terraform-substrate.template.yaml",
),
parameters: {
CreateOIDCProvider: props?.createOidcProvider === false ? "false" : "true",
},
});
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,363 @@
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). HcptfIamManagementPolicy DERIVES FROM 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) but is
# DELIBERATELY STRICTER — it is NOT a byte-identical mirror. Do not "reconcile"
# the two by copying this file's statements back, or vice versa; the divergences
# below are load-bearing and were required by the 2026-07-30 security review
# (findings C1-C5, one confirmed critical + one high):
#
# 1. ROLE PATH SCOPING (review finding C2). The SAM copy's Resource
# `role/*` on the boundary-gated statements is justified there by SAM
# auto-generating execution roles at path / with no settable RolePath —
# a path condition would break every SAM deploy. THAT RATIONALE DOES NOT
# TRANSFER: Terraform's aws_iam_role supports `path` and `name_prefix`.
# So every role-WRITE statement here is scoped to the Terraform-owned path
# `role/tf-managed/*`. Terraform configs MUST set path = "/tf-managed/" on
# every role they create; a role created anywhere else is denied. The path
# is deliberately NOT `hcptf-*`, which would collide with the substrate's
# own hcptf-* apply/plan roles under DenySelfMutation's wildcard.
# 2. READ AND WRITE SPLIT (review findings C1, C3, C4). The SAM copy's
# IAMRoleReadAndDelete grants iam:UpdateAssumeRolePolicy / DeleteRole /
# DetachRolePolicy / DeleteRolePolicy / UpdateRole on Resource "*"
# unconditioned — a confirmed privilege-escalation primitive (repoint the
# AdministratorAccess CDK bootstrap role's trust policy, then assume it
# cross-account) that DenySelfMutation's three name patterns do not cover.
# Here those actions are split: reads stay on "*" (Terraform data sources
# need them), every destructive/mutating action is confined to
# `role/tf-managed/*`. This closes the escalation at the root instead of
# chasing it with a denylist.
# 3. PASSROLE SCOPING (review finding C5). The SAM copy passes any role to
# Lambda (its comment claims SAM-role scoping the Resource does not
# express). Here PassRole is confined to `role/tf-managed/*`, so one
# workspace cannot attach another workspace's execution role to a function
# it controls — that path performs no IAM write and would otherwise evade
# every boundary gate and Deny in this document.
# 4. DENYSELFMUTATION SCOPE. Extended beyond the substrate's own principals to
# cdk-hnb659fds-* (AdministratorAccess bootstrap roles),
# OrganizationAccountAccessRole, and seahaven-* (detective-control roles
# such as the Config recorder role, which no SCP on prod/nonprod protects
# from iam:DeleteRole). Defense in depth behind the path scoping above.
#
# The SAM copy retains its adjudicated accepted risks because SAM's constraints
# are real; this file has no such excuse. KNOWN OPEN ITEM (pre-existing, not
# introduced here): the org's ProtectPrivilegedRoles SCP encodes exactly the
# protection in (4) but is attached ONLY to the security OU — extending it to
# prod/nonprod is the durable org-level fix and is tracked separately.
#
# 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).
Parameters:
CreateOIDCProvider:
Type: String
Default: "true"
AllowedValues: ["true", "false"]
Description: >-
Set to false if the app.terraform.io OIDC provider already exists in this
account. An account holds exactly ONE provider per URL, so an unconditional
create collides. Because the provider is Retain, a FIRST-create rollback
(caused by any other resource in this stack failing) leaves the provider
behind as an orphan and the stack in ROLLBACK_COMPLETE — which cannot be
updated. Recovery: delete the stack, then either
`aws iam delete-open-id-connect-provider --open-id-connect-provider-arn
arn:aws:iam::<acct>:oidc-provider/app.terraform.io` before retrying, or
redeploy with this parameter false. Same idempotency affordance the sibling
deploy-substrate template carries for the GitHub provider.
Conditions:
ShouldCreateOIDCProvider: !Equals [!Ref CreateOIDCProvider, "true"]
Resources:
# ---------------------------------------------------------------------------
# HCP Terraform OIDC provider
#
# Created by default: 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 — see the parameter above for the
# first-create rollback trap this condition exists to make recoverable.
# ---------------------------------------------------------------------------
TerraformCloudOIDCProvider:
Type: AWS::IAM::OIDCProvider
Condition: ShouldCreateOIDCProvider
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 AND land on the Terraform-owned
# path. Two independent gates: the boundary caps what the role can do,
# the path caps which roles this policy can touch at all. Terraform
# configs set path = "/tf-managed/" on every aws_iam_role.
- Sid: IAMCreateRoleWithBoundary
Effect: Allow
Action:
- iam:CreateRole
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/tf-managed/*"
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/tf-managed/*"
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/tf-managed/*"
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.
# Path-scoped as well as boundary-pinned: the condition constrains WHICH
# boundary may be set, not WHICH role receives it. Unscoped (as in the
# SAM copy) this is a one-way denial-of-service — applying the Lambda
# runtime boundary to the CDK bootstrap execution role collapses its
# permissions, and DenyBoundaryTampering below then blocks removal by
# this same principal (2026-07-30 review finding C3).
- Sid: IAMPutPermissionsBoundary
Effect: Allow
Action:
- iam:PutRolePermissionsBoundary
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/tf-managed/*"
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-*"
# Extended beyond the SAM copy's three patterns (2026-07-30 review
# findings C1/C3/C4). cdk-hnb659fds-* carries AdministratorAccess
# and deploys this very stack; OrganizationAccountAccessRole is the
# org break-glass path; seahaven-* covers detective-control roles
# (e.g. the Config recorder role) that the protect-security-baseline
# SCP does NOT shield from iam:DeleteRole. Defense in depth — the
# path scoping on the write statements is the primary control.
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-*"
- !Sub "arn:aws:iam::${AWS::AccountId}:role/OrganizationAccountAccessRole"
- !Sub "arn:aws:iam::${AWS::AccountId}:role/seahaven-*"
# READ-ONLY on every role/policy in the account. Terraform data sources
# and refresh legitimately need to read arbitrary roles; none of these
# actions can modify anything, so Resource "*" is safe here.
- Sid: IAMReadOnly
Effect: Allow
Action:
- iam:GetRole
- iam:GetRolePolicy
- iam:ListAttachedRolePolicies
- iam:ListRolePolicies
- iam:ListRoles
- iam:GetPolicy
- iam:GetPolicyVersion
- iam:ListPolicies
- iam:ListPolicyVersions
Resource: "*"
# DESTRUCTIVE / MUTATING role actions — confined to the Terraform-owned
# path. The SAM copy grants these on Resource "*" unconditioned, which
# the 2026-07-30 review confirmed as a critical escalation primitive
# (finding C1): iam:UpdateAssumeRolePolicy on "*" lets the principal
# repoint the AdministratorAccess CDK bootstrap role's trust policy to
# an external account and assume it. Path scoping closes that at the
# root rather than enumerating protected names.
- Sid: IAMRoleWriteScoped
Effect: Allow
Action:
- iam:DeleteRole
- iam:DeleteRolePolicy
- iam:DetachRolePolicy
- iam:TagRole
- iam:UntagRole
- iam:UpdateRole
- iam:UpdateRoleDescription
- iam:UpdateAssumeRolePolicy
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/tf-managed/*"
# PassRole — Terraform passes the execution roles it created (which are
# on the tf-managed path, boundary-gated above) to the Lambda service.
# Path-scoped, not role/*: unscoped, one workspace's apply role could
# attach ANOTHER workspace's or a SAM stack's execution role to a
# function it controls and run arbitrary code as that identity — a path
# that performs no IAM write and so evades every boundary gate and Deny
# in this document (2026-07-30 review finding C5). Other target services
# (scheduler, apigateway, ...) are NOT granted: a stack that needs one
# adds a scoped PassRole statement to its own apply role at migration.
- Sid: IAMPassRole
Effect: Allow
Action:
- iam:PassRole
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/tf-managed/*"
Condition:
StringEquals:
"iam:PassedToService": "lambda.amazonaws.com"