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"