seahaven-account-baseline/lib/terraform-substrate/terraform-substrate.template.yaml
Adam Moussa ea27635ef2
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).
2026-07-30 16:31:34 -04:00

266 lines
13 KiB
YAML

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"