feat(iam): lock app-owned HCP IAM and add bootstrap SCP (PLAT-143) (#137)

* feat(iam): lock app-owned HCP IAM and add bootstrap SCP (PLAT-143)

* fix(iam): pin HCP boundary ARNs and bootstrap trust window (PLAT-143)

Null on iam:PermissionsBoundary accepted any ceiling, including AdministratorAccess. Import apply cannot self-mutate hcptf-* while bootstrap trust is iam-bootstrap only; add a time-boxed exact StringEquals workspace grant instead of StringLike.
This commit is contained in:
Adam Moussa 2026-09-02 15:22:48 +00:00 • committed by GitHub
parent 35dc61b806
commit b02f52b805
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1349 additions and 251 deletions

339
README.md
View file

@ -31,7 +31,8 @@ are noted):
| `seahaven-backup-offsite` | 328440206208 | us-west-2 | Governance-locked offsite copy vault (C-7) |
| `seahaven-org-governance` | 328440206208 | us-east-1 | AWS Organizations OU tree + SCPs (incl. the cdk-imported external-dev guardrails) |
| `seahaven-external-dev-baseline` | 396287094661 | us-east-1 | Member-account baseline: Config, GuardDuty, Security Hub (FSBP + CIS v3.0), Access Analyzer, flow logs, budget |
| `seahaven-terraform-substrate` | 396287094661 | us-east-1 | Staged manually for SHOC backend/frontend adoption; exact HCP roles and deploy boundaries referencing the existing OIDC provider |
| `seahaven-terraform-substrate` | 011934824531, 710827005802 | us-east-1 | Prod/dev HCP substrate: OIDC + shared IAM manager + eight existing `hcptf-*` pairs (DeletionPolicy Retain). Pending PLAT-147 delete after consumer imports. New prod/dev HCP IAM is not added here. |
| `seahaven-terraform-substrate` | 396287094661 | us-east-1 | Staged manually for SHOC backend/frontend adoption; exact HCP roles and deploy boundaries referencing the existing OIDC provider. Stays (PLAT-148). |
| `seahaven-security-baseline` | 001520130573 | us-east-1 | Member-account baseline for the delegated security-admin account (same construct set) |
| `seahaven-dev-baseline` | 710827005802 | us-east-1 | Member-account baseline for internal dev/staging (org-managed detection — no local GuardDuty/SecurityHub) |
| `seahaven-prod-baseline` | 011934824531 | us-east-1 | Member-account baseline for production workloads (org-managed detection; mgmt account frozen for new workloads) |
@ -52,7 +53,7 @@ the TypeScript source — no separate compile step needed for `cdk synth` /
| `tsconfig.json` | TypeScript compiler options (`outDir: cdk.out`) |
| `package.json` | Pinned `aws-cdk-lib`, CDK CLI, and the `build` / `synth` / `diff` / `deploy` npm scripts |
`bin/app.ts` synthesizes fifteen stacks across three regions and five accounts:
`bin/app.ts` synthesizes these stacks across three regions and five accounts:
| Construct id | Stack name | Account | Region | Source |
|---|---|---|---|---|
@ -69,6 +70,8 @@ the TypeScript source — no separate compile step needed for `cdk synth` /
| `prod-baseline` | `seahaven-prod-baseline` | 011934824531 | us-east-1 | `lib/member-baseline-stack.ts` (orgManagedDetection) |
| `deploy-substrate-prod` | `seahaven-deploy-substrate` | 011934824531 | us-east-1 | `lib/deploy-substrate-stack.ts` |
| `deploy-substrate-dev` | `seahaven-deploy-substrate` | 710827005802 | us-east-1 | `lib/deploy-substrate-stack.ts` |
| `terraform-substrate-prod` | `seahaven-terraform-substrate` | 011934824531 | us-east-1 | `lib/terraform-substrate-stack.ts` (pending PLAT-147 delete) |
| `terraform-substrate-dev` | `seahaven-terraform-substrate` | 710827005802 | us-east-1 | `lib/terraform-substrate-stack.ts` (pending PLAT-147 delete) |
| `terraform-substrate-external-dev` | `seahaven-terraform-substrate` | 396287094661 | us-east-1 | `lib/terraform-substrate-stack.ts` |
| `dynamodb-cmk-prod` | `seahaven-dynamodb-cmk` | 011934824531 | us-east-1 | `lib/dynamodb-cmk-stack.ts` |
| `alarm-topic-prod` | `seahaven-alarm-topic` | 011934824531 | us-east-1 | `lib/alarm-topic-stack.ts` |
@ -216,22 +219,45 @@ created with the boundary already attached.
### 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
deploy `seahaven-terraform-substrate` into member accounts that host
Terraform-managed workloads (currently seahaven-prod, seahaven-dev, and
external-dev; never mgmt — mgmt stays SAM until its stacks migrate out).
Prod/dev use the shared IAM-management policy. External-dev references its
existing `app.terraform.io` provider and carries only exact SHOC
import/adoption roles:
**Durable owner for prod/dev per-workload HCP IAM is app Terraform (PLAT-144).**
Console / one-shot CLI owns only:
- the existing `app.terraform.io` OIDC provider (already `Retain`; an account
holds one per URL),
- one general apply role and one general plan role per prod and dev account
(`hcptf-bootstrap` / `hcptf-bootstrap-plan`, created by
`scripts/create-hcptf-bootstrap-roles.sh`, not a CDK stack).
Do not manage prod/dev workload IAM (`hcptf-<stack>` pairs, Lambda exec
roles, `policy/tf-managed/<stack>` ceilings) in the console. Do not append
new prod/dev `hcptf-<stack>` pairs to this template. New prod/dev HCP stacks
do not need an org-baseline IAM PR.
**External-dev IAM stays in this repo (PLAT-148).** SHOC backend/frontend HCP
roles, SHOC deploy/runtime boundaries, `shoc-frontend-resources.ts`, and
stack `terraform-substrate-external-dev` are not imported into `shoc-backend`
or `shoc-frontend-new`. New external-dev IAM still lands here.
**Deploy-substrate stays for remaining SAM (PLAT-150).**
`github-cfn-execution-role`, `seahaven-cfn-exec-iam-management`, and the
enumerated SAM `StringEquals` allow-list are unchanged. Do not add `ArnLike`
there (PLAT-52 AC1). Do not add new HCP workloads to those four Sids.
Prod/dev still carry, until PLAT-147 deletes those two stacks:
- 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 the enumerated
`seahaven-lambda-execution-boundary` allow-list owned by the
deploy-substrate stack — hence the explicit stack dependency in
`bin/app.ts`) plus the `DenyBoundaryTampering` / `DenyBoundaryPolicyEdit`
/ `DenySelfMutation` backstops,
- the `seahaven-hcptf-iam-management` guardrail policy (enumerated
`seahaven-lambda-execution-boundary-*` allow-list; frozen, do not append),
- the eight existing prod `hcptf-<stack>` pairs with `DeletionPolicy: Retain`.
External-dev still carries:
- external-dev-only deploy boundaries
`shoc-backend-{tf-poc,dev,staging}-deploy-boundary`. Each is the maximum
current policy for one exact `githubdeploy-shoc-backend-*` role. Dev
@ -245,32 +271,33 @@ import/adoption roles:
data plane. They deliberately exclude the managed policy's 2026
Bedrock/Marketplace additions.
**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:
**`seahaven-hcptf-iam-management` 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,
`aws_iam_role` they create for workload execution** — 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`).
`OrganizationAccountAccessRole` and `seahaven-*`.
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.
org-level control for the same class is `protect-privileged-roles` on prod
and nonprod (PLAT-145), covering `hcptf-bootstrap*` plus the break-glass /
CDK / `githubdeploy-*` set already on the security OU.
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.
The eight existing prod/dev per-workspace pairs are imported into the owning
app, not recreated. After import they are Retain-removed from this template
and the prod/dev stacks are deleted. See the first-apply and import runbooks
below. Do not batch those consumer PRs; pilot is `afi-backup-monitor`.
**External-dev SHOC role adoption is a staged CloudFormation import, not a
normal first deploy.** Six roles exist today:
@ -436,157 +463,153 @@ inline policy name.
**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.
(`<stack>-<env>`, one state file = one blast radius). Dedicated
`iam-bootstrap` workspace in each prod/dev project (Manual apply only).
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. Never project-scoped variable sets.
**Reference implementation:** first workload was `afi-backup-monitor` in
seahaven-prod (PLAT-56). Copy
`Sea-Haven-Industries/afi-backup-monitor` `terraform/` and the live
`hcptf-afi-backup-monitor*` / `hcptf-afi-backup-monitor-plan` statements in
this template rather than inventing new IAM shapes.
**Two-principal model (prod/dev HCP, PLAT-149).** Replace enumerated
`StringEquals` on `iam:PermissionsBoundary` with a factory vs scoped split.
**Migration checklist (per stack, in order):**
- **General apply role `hcptf-bootstrap`:** trust pinned to
`organization:seahaven:project:seahaven-<env>:workspace:iam-bootstrap:run_phase:apply`
(exact `StringEquals` on `aud` and `sub`; never `StringLike` on
`run_phase` or workspace). IAM writes on `role/tf-managed/*` and
`policy/tf-managed/*`. `CreatePolicy` / `CreatePolicyVersion` only here.
`CreateRole` / `PutRolePolicy` / `AttachRolePolicy` /
`PutRolePermissionsBoundary` on `tf-managed` roles require
`iam:PermissionsBoundary` `StringLike` `policy/tf-managed/*` or
`policy/seahaven-lambda-execution-boundary*`. `Null` false is not enough:
it would accept `AdministratorAccess` as the ceiling. Must not mutate
`githubdeploy-*`, `github-cfn-execution-role`, `cdk-hnb659fds-*`,
`OrganizationAccountAccessRole`, `seahaven-*`. SCP
`protect-privileged-roles` covers `hcptf-bootstrap*` in prod and nonprod
(OAA and CDK may still update trust). Manual apply. Not in external-dev.
- **General plan role `hcptf-bootstrap-plan`:** `ViewOnlyAccess` plus a
refresh sidecar. Never `ReadOnlyAccess`. Never IAM writes. Trust
`run_phase:plan` on the same `iam-bootstrap` workspace.
- **Scoped apply role `hcptf-<stack>`:** trust pinned to that stack's
workspace and `run_phase:apply`. No `seahaven-hcptf-iam-management`. May
manage `role/tf-managed/<prefix>-*` with `iam:PermissionsBoundary`
`StringLike` this stack's `policy/tf-managed/<prefix>*`, the shared
`seahaven-lambda-execution-boundary`, or
`seahaven-lambda-execution-boundary-<stack>`. May not `CreatePolicy` /
`CreatePolicyVersion`. May not `UpdateAssumeRolePolicy`, `DeleteRole`, or
`PutRolePolicy` on `hcptf-*` (including itself). Need more apply-role
permission later: `--allow-workspace <stack>-<env>` on the create script,
point that workspace's `TFC_AWS_*` at `hcptf-bootstrap`, apply, retarget
vars, re-run the script with no extra workspace. Do not leave a stack
workspace on bootstrap trust.
- **Scoped plan role:** `ViewOnlyAccess` plus a scoped refresh sidecar.
0. **Freeze the app's SAM/CDK CD** (remove or disable the deploy workflow) so
HCP Terraform becomes the sole deploy path before the first apply. Leave
the source-account stack frozen until cutover.
1. **Secrets first.** Create exact secret shells in the target account; strip
trailing newlines/whitespace before `put-secret-value` (a trailing `\n`
breaks HTTP headers at runtime). Capture ARNs. Never put secret *values*
in Terraform state (ARN references only).
2. **HCP workspace** in the target account's project (`<stack>-<env>`). Apply
method **Manual**; automatic speculative plans on if VCS-connected;
working directory `terraform/`. (CLI `terraform plan` runs are inherently
speculative.)
3. **Substrate PR** to this repo appending `hcptf-<stack>-plan` and
`hcptf-<stack>` (see 3a/3b). 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). **If the stack creates Lambda execution roles, this same PR
must also add `seahaven-lambda-execution-boundary-<stack>`** per the
WIDENING PATH in `lib/deploy-substrate/deploy-substrate.template.yaml`
(floor plus that stack's data plane, **exact** secret ARNs from step 1,
no `secret:afi-*` patterns) **and** append that policy's ARN to both
guardrail StringEquals allow-lists. Do not add data-plane to the shared
`seahaven-lambda-execution-boundary` document. The guardrail forces every
Terraform-created role to carry a listed boundary; an unlisted or
floor-only boundary deploys green, then every data-plane call is denied
at first invoke and async/DLQ writes are discarded silently. IAM roles and
boundary policies = mandatory cross-family review +
`/sh-security-review` on the diff.
This does not re-open PLAT-52 AC1. On the HCP path, `CreatePolicy` lives only
on `hcptf-bootstrap`, which is not `githubdeploy-seahaven-org-baseline`.
Prefix matching is `StringLike` on the bootstrap/scoped HCP documents, not
on the SAM guardrail. Do not add `ArnLike` to deploy-substrate.
3a. **Plan role (required for every stack):** attach
`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) **plus** a scoped
plan-refresh sidecar inline policy. ViewOnly alone is insufficient for
Terraform refresh after partial apply — it lacks `iam:GetRole`,
`events:DescribeRule`, and several Lambda/S3 reads. Sidecar minimum:
`iam:GetRole` / related reads on `role/tf-managed/<prefix>-*`;
`events:DescribeRule` (and list-targets/tags as needed) on
`rule/<prefix>-*`; `lambda:*` (or at least the Get*/List* the provider
uses) on `function:<prefix>-*` / `layer:<prefix>-*`; `s3:Get*` /
`s3:ListBucket` on the stack artifact bucket. **No** IAM writes, **no**
guardrail-policy attach on the plan role. Copy
`afi-backup-monitor-plan-refresh` on `hcptf-afi-backup-monitor-plan`.
**First-apply runbook (new prod/dev HCP stack):**
3b. **Apply role (Lambda/EventBridge stacks):** attach
`seahaven-hcptf-iam-management` plus stack-scoped service statements.
Prefer prefix-scoped `lambda:*` on `function:<prefix>-*` /
`layer:<prefix>-*`, `events:*` on `rule/<prefix>-*`, and bucket-scoped
`s3:*` on the artifact bucket — do **not** enumerate individual provider
Get* APIs (`GetFunctionCodeSigningConfig`, `GetBucketAcl`, …); that list
lags and fails first apply. Keep list/describe-on-`*` only where the
service requires it (e.g. `lambda:ListFunctions`). Copy
`afi-backup-monitor-services` on `hcptf-afi-backup-monitor`.
4. **Deploy substrate** to `UPDATE_COMPLETE`. 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`); and if step 3 added a
per-workload boundary, confirm the deployed default version of
`seahaven-lambda-execution-boundary-<stack>` carries the stack's
data-plane statements (`aws iam get-policy-version`) — role verification
alone never checks boundary content. Mechanical template↔deployed policy
reconcile as for other substrate policies.
5. 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. Auto-apply stays OFF until the stack is sealed.
6. **App Terraform PR:** every `aws_iam_role` sets `path = "/tf-managed/"` and
`permissions_boundary` to that stack's
`seahaven-lambda-execution-boundary-<stack>` ARN (not the shared name,
once the per-workload policy exists); package Lambda/layer zips via an account artifact S3 bucket
and `aws_s3_object` `content_base64` (HCP plan and apply run on separate
workers and do not share local `archive_file` paths — see
`afi-backup-monitor/terraform/artifacts.tf`); functions `depends_on` their
IAM policies before create; commit `.terraform.lock.hcl` with
multi-platform hashes.
7. **First Manual apply** from the HCP workspace (not local apply against
prod). Tolerate partial state on permission misses; widen the apply/plan
roles and retry. Confirm all expected resources exist in the target
account.
8. **Live-path proof:** real invoke of every critical function must hit real
external APIs / Slack (not synth or simulate alone) before cutover.
9. **Cutover + decommission:** disable source-account schedules (e.g.
EventBridge rules); observe a clean prod path; delete the source
CloudFormation/CDK stack per the decommission playbook; sweep or retain
log groups deliberately; delete source secrets last.
10. **Docs:** update Confluence AWS Architecture Map and the stack ops page;
promote durable gotchas to the convention ledger when they are general.
1. Freeze the app's SAM/CDK CD. Secrets first (ARN references only in state).
2. HCP workspace `<stack>-<env>` exists. Auto-apply off. Vars still empty.
IAM lives in this workspace's state, so this workspace must assume
`hcptf-bootstrap` for the first apply. Default bootstrap trust does not
include it. Never a project-scoped variable set.
3. `scripts/create-hcptf-bootstrap-roles.sh --account prod|dev
--allow-workspace <stack>-<env>` (OAA). Trust stays exact `StringEquals`
on `iam-bootstrap` plus this one workspace. Never `StringLike`.
4. Point this workspace's `TFC_AWS_APPLY_ROLE_ARN` /
`TFC_AWS_PLAN_ROLE_ARN` at `hcptf-bootstrap` / `hcptf-bootstrap-plan`.
5. App PR adds `aws_iam_role` plan/apply (trust exact `StringEquals`), a
boundary at `path = "/tf-managed/"`, and exec roles with that boundary.
Copy `examples/hcptf-workspace-iam/hcp_iam.tf.example`. Cross-family
review + `/sh-security-review` still apply to IAM in the app PR. There is
no org-baseline IAM PR for the new stack.
6. One Manual apply. Roles and boundary exist (tolerate partial state on
non-IAM resources).
7. Switch workspace vars to the new scoped ARNs. Re-run the create script
with no `--allow-workspace` so trust is `iam-bootstrap` only again.
8. Second Manual apply with the scoped role. Prove the stack. Then seal.
9. Live-path proof, cutover, docs as before.
Plan role: `ViewOnlyAccess` (never `ReadOnlyAccess`) plus a scoped
plan-refresh sidecar. Apply role: scoped IAM statements from
`lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl` plus prefix-scoped service
wildcards. Do not enumerate provider Get* APIs.
**Import runbook (existing eight prod stacks, PLAT-146).** Import, do not
recreate. Role names and `TFC_AWS_*_ROLE_ARN` stay the same. Pilot is
`afi-backup-monitor` only; do not batch the remaining seven.
Repos that must change: `afi-backup-monitor`, `front-integrations`,
`paychex-integrations`, `sh-openswe-traces`, `procurement-ingest`,
`seahaven-site`, `meal-order-manager`, `seahaven-door-unlock-api`.
Repos that do not change: SHOC (`shoc-backend`, `shoc-frontend-new`),
remaining SAM / unmigrated stacks.
Each PR adds `aws_iam_role` / inline policy resources matching live names,
`terraform import` (see `examples/hcptf-workspace-iam/hcp_iam.tf.example`),
and drops the attached `seahaven-hcptf-iam-management` once the scoped
statements live on the role. Role names stay the same. The import apply
cannot run as `hcptf-<stack>`: live DenySelfMutation denies
`DetachRolePolicy` / `PutRolePolicy` on `hcptf-*`. Use the same
`--allow-workspace` window as first-apply, point `TFC_AWS_*` at bootstrap
for that one Manual apply, then retarget the original scoped ARNs and
revoke the extra trust. Lambda `permissions_boundary` may keep pointing
at `seahaven-lambda-execution-boundary-<stack>` in deploy-substrate for this
pass.
**Prod/dev substrate delete (PLAT-147).** After all eight imports:
1. Inventory `seahaven-hcptf-iam-management` attachments
(`scripts/delete-terraform-substrate-prod-dev.sh --account prod --inventory`).
None may remain.
2. Remove the eight prod role pairs from the template (they already have
`DeletionPolicy: Retain`) so CloudFormation forgets them without deleting.
3. Remove `terraform-substrate-prod` and `terraform-substrate-dev` from
`bin/app.ts` and `.github/workflows/deploy.yaml`. Keep
`terraform-substrate-external-dev`.
4. Delete stacks in `011934824531` and `710827005802` only. OIDC is Retain.
`seahaven-hcptf-iam-management` deletes with those stacks. Do not delete
`terraform-substrate-external-dev`. Do not strip SHOC resources from the
shared YAML while that stack still synthesizes them.
**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
usable in an IAM condition. 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.
Treat "still using the bootstrap ARN" as a defect; check HCP var sets after
each migration.
**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.
principal with workspace read. Secrets stay in Secrets Manager / SSM and are
referenced by ARN: do not manage secret *values* in Terraform.
**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.
**Rollback of terraform-substrate:** inventory attachments first. Deleting
prod/dev while any `hcptf-*` still attaches `seahaven-hcptf-iam-management`
fails or strips the backstops. Only the **provider** is `Retain`. The
**guardrail policy is deleted with the stack**. After PLAT-147 the standing
control for `hcptf-bootstrap*` is the prod/nonprod SCP, and scoped apply
roles carry their own DenySelfMutation inline. Do not delete
`terraform-substrate-external-dev`. Workspaces holding state must be migrated
or destroyed HCP-side first; deleting the OIDC provider strands them mid-run.
**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.
provider with `aws iam delete-open-id-connect-provider` 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.
### CloudTrail (audit finding C-1)

View file

@ -200,20 +200,14 @@ const deploySubstrateDev = new DeploySubstrateStack(app, "deploy-substrate-dev",
});
// ── 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/external-dev ONLY — mgmt stays SAM
// (Terraform POC decision 2026-07-30; the mgmt POC substrate was rolled back
// the same day). External-dev references its existing provider and uses
// workload-specific inline policies instead of the shared IAM manager.
// The guardrail policy names the seahaven-lambda-execution-boundary ARN only
// Prod/dev instances still exist until PLAT-147: they own the live eight
// hcptf-<stack> pairs (DeletionPolicy Retain) and seahaven-hcptf-iam-management.
// Do not append new prod/dev workspace roles here. Do not add a CDK stack for
// hcptf-bootstrap (CLI-owned, PLAT-145). External-dev stays: SHOC IAM is not
// moving (PLAT-148). deploy-substrate stays for remaining SAM (PLAT-150).
// The guardrail policy names seahaven-lambda-execution-boundary ARNs 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.
// dependency below keeps deploy-substrate first while these stacks remain.
const terraformSubstrateProd = new TerraformSubstrateStack(
app,
"terraform-substrate-prod",

View file

@ -0,0 +1,264 @@
# Example: import an existing prod/dev hcptf-<stack> pair into app Terraform
# (PLAT-146). Copy into the consumer repo's terraform/ directory. Replace
# locals, then `terraform import` (or keep the import blocks) on a Manual
# apply. Do not recreate the role. Role names stay `hcptf-STACK` /
# `hcptf-STACK-plan`.
#
# Live `seahaven-hcptf-iam-management` DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). The stack workspace
# cannot apply this file while TFC_AWS_* still points at hcptf-STACK, and
# hcptf-bootstrap trust is exact StringEquals for workspace iam-bootstrap
# only. Import apply sequence:
# 1. scripts/create-hcptf-bootstrap-roles.sh --account prod|dev \
# --allow-workspace STACK-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (import + detach seahaven-hcptf-iam-management +
# put scoped inline).
# 4. Point TFC_AWS_* back at hcptf-STACK / hcptf-STACK-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap only.
# Later apply-role IAM edits use the same window. Do not add StringLike
# on bootstrap trust.
#
# SAM-only repos and SHOC/external-dev do not use this file.
locals {
account_id = "011934824531" # seahaven-prod; use 710827005802 for seahaven-dev
hcp_project = "seahaven-prod"
hcp_workspace = "STACK-prod"
apply_role = "hcptf-STACK"
plan_role = "hcptf-STACK-plan"
stack_name = "STACK"
stack_prefix = "STACK-"
}
data "aws_iam_policy_document" "hcptf_apply_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply",
]
}
}
}
data "aws_iam_policy_document" "hcptf_plan_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["arn:aws:iam::${local.account_id}:oidc-provider/app.terraform.io"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = [
"organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan",
]
}
}
}
# Rendered from lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl. CreatePolicy
# stays on hcptf-bootstrap only. Exec-role writes are prefix-scoped. Boundary
# ARNs are StringLike-pinned (not Null); AdministratorAccess is not accepted.
# The role cannot PutRolePolicy on hcptf-* (including itself).
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
effect = "Deny"
actions = ["iam:CreatePolicy", "iam:CreatePolicyVersion"]
resources = ["*"]
}
statement {
sid = "CreateExecRoleWithBoundary"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "MutateExecRoleWithBoundary"
effect = "Allow"
actions = [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
sid = "WriteExecRoles"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
}
statement {
sid = "PassExecRolesToLambda"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
}
}
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoles",
]
resources = ["*"]
}
statement {
sid = "DenySelfMutation"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = [
"arn:aws:iam::${local.account_id}:role/hcptf-*",
"arn:aws:iam::${local.account_id}:role/github-cfn-execution-role",
"arn:aws:iam::${local.account_id}:role/githubdeploy-*",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/seahaven-*",
]
}
statement {
sid = "DenyBoundaryTampering"
effect = "Deny"
actions = ["iam:DeleteRolePermissionsBoundary", "iam:DeleteUserPermissionsBoundary"]
resources = [
"arn:aws:iam::${local.account_id}:role/*",
"arn:aws:iam::${local.account_id}:user/*",
]
}
statement {
sid = "DenyBoundaryPolicyEdit"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = ["arn:aws:iam::${local.account_id}:policy/seahaven-*"]
}
}
resource "aws_iam_role" "hcptf_apply" {
name = local.apply_role
assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json
max_session_duration = 3600
# Empty list detaches seahaven-hcptf-iam-management after import.
managed_policy_arns = []
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role" "hcptf_plan" {
name = local.plan_role
assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json
max_session_duration = 3600
managed_policy_arns = ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"]
tags = {
Owner = "adam@seahavenind.com"
ManagedBy = "terraform"
}
}
resource "aws_iam_role_policy" "hcptf_scoped_iam" {
name = "scoped-iam-management"
role = aws_iam_role.hcptf_apply.id
policy = data.aws_iam_policy_document.hcptf_scoped_iam.json
}
# Also import the existing service inline policy (name matches CFN PolicyName)
# and the plan-refresh sidecar. Copy those documents from
# lib/terraform-substrate/terraform-substrate.template.yaml. Do not invent a
# new Get* allow-list.
#
# import {
# to = aws_iam_role.hcptf_apply
# id = "hcptf-STACK"
# }
# import {
# to = aws_iam_role.hcptf_plan
# id = "hcptf-STACK-plan"
# }
# import {
# to = aws_iam_role_policy.services
# id = "hcptf-STACK:STACK-services"
# }
# import {
# to = aws_iam_role_policy.plan_refresh
# id = "hcptf-STACK-plan:STACK-plan-refresh"
# }

View file

@ -114,22 +114,20 @@ Description: >-
# ready to roll back — and is explicitly OUT OF SCOPE of INFRA-186. Until that
# lands, the two copies stay divergent and that is the intended state.
#
# COUPLING (PLAT-52): the SHARED policy's ManagedPolicyName and ARN
# (seahaven-lambda-execution-boundary) stay unchanged until
# PermissionsBoundaryUsageCount is 0 in the account. Four Conditions in
# SamCfnIamManagementPolicy below, and four more in HcptfIamManagementPolicy
# in lib/terraform-substrate/terraform-substrate.template.yaml, pin an
# enumerated StringEquals list of acceptable boundary ARNs: the shared ARN
# plus each seahaven-lambda-execution-boundary-<workload> ARN. The two files'
# lists MUST match (mechanical sorted-JSON compare). A rename of the SHARED
# policy still fails SILENTLY — an IAM condition naming a non-existent policy
# simply never matches, so the escalation control evaporates rather than
# error — and would additionally force a CloudFormation REPLACEMENT that any
# role carrying the boundary would block. Adding a workload is a NEW named
# ManagedPolicy in this file AND one ARN appended to both allow-lists. Do
# not use ArnLike on seahaven-lambda-execution-boundary-*: githubdeploy-seahaven-org-baseline
# COUPLING (PLAT-52, frozen for HCP by PLAT-143): the SHARED policy's
# ManagedPolicyName and ARN (seahaven-lambda-execution-boundary) stay unchanged
# until PermissionsBoundaryUsageCount is 0 in the account. Four Conditions in
# SamCfnIamManagementPolicy below pin an enumerated StringEquals list of
# acceptable boundary ARNs: the shared ARN plus each
# seahaven-lambda-execution-boundary-<workload> ARN. Do not use ArnLike on
# seahaven-lambda-execution-boundary-*: githubdeploy-seahaven-org-baseline
# can CreatePolicy via CFN, so a conforming-name policy would become an
# acceptable ceiling without touching the eight pin sites.
# acceptable ceiling without touching the pin sites. New HCP stacks do not
# append here (PLAT-150); their ceilings are policy/tf-managed/<stack> created
# by hcptf-bootstrap. The matching four Sids in seahaven-hcptf-iam-management
# stay frozen until that policy is deleted with the prod/dev terraform-substrate
# stacks (PLAT-147). Adding a remaining SAM workload is still a NEW named
# ManagedPolicy in this file AND one ARN appended to the SAM allow-list only.
#
# SIZE BUDGET: an attached managed policy document is capped at 6,144 characters
# (whitespace excluded). Measure with len(json.dumps(doc, separators=(',',':')))
@ -270,18 +268,19 @@ Resources:
# with a custom log-group name outside /aws/lambda* silently loses ALL
# logs — add a scoped logs statement for the custom group or keep the
# default group name.
# 3. Measure THAT policy against 6144 (SIZE BUDGET). Also measure both
# guardrail PolicyDocuments after step 4 — each new ARN is copied into
# four Sids in each guardrail.
# 4. Append the new ARN to BOTH allow-lists (SamCfnIamManagementPolicy in
# this file AND HcptfIamManagementPolicy in
# terraform-substrate.template.yaml). The lists must match. Do not use
# ArnLike. Both review gates run and neither discharges the other: the
# GPT-4.1 cross-family review against the real diff, and /sh-security-review
# (IaC/IAM is on the mandatory surface). CLI down = review outstanding.
# 5. Merge and let CI deploy deploy-substrate-prod / deploy-substrate-dev
# (and terraform-substrate) to UPDATE_COMPLETE, THEN deploy the workload
# with PermissionsBoundary set to THIS stack's ARN (not the shared name).
# 3. Measure THAT policy against 6144 (SIZE BUDGET). Also measure the SAM
# guardrail PolicyDocument after step 4 — each new ARN is copied into
# four Sids.
# 4. Remaining SAM workloads: append the new ARN to SamCfnIamManagementPolicy
# only. Do not use ArnLike. Do not append to seahaven-hcptf-iam-management
# (frozen; prod/dev HCP IAM is leaving that policy). New HCP stacks create
# policy/tf-managed/<stack> via hcptf-bootstrap instead of a named policy
# here. Both review gates run and neither discharges the other: the
# GPT-4.1 cross-family review against the real diff, and /sh-security-review
# (IaC/IAM is on the mandatory surface). CLI down = review outstanding.
# 5. Merge and let CI deploy deploy-substrate-prod / deploy-substrate-dev
# to UPDATE_COMPLETE, THEN deploy the SAM workload with
# PermissionsBoundary set to THIS stack's ARN (not the shared name).
# ORDERING IS NOT ENFORCED BY CLOUDFORMATION AND THIS IS THE MOST IMPORTANT
# SENTENCE HERE: the workload's deploy SUCCEEDS even against a stale or
# missing-content boundary, because the guardrail gates check that a listed
@ -729,10 +728,12 @@ Resources:
- !Ref AWS::NoValue
# ── FURTHER PER-WORKLOAD DATA-PLANE ────────────────────────────────
# Do not add statements here. Create seahaven-lambda-execution-boundary-<stack>
# below and append its ARN to both guardrail allow-lists (WIDENING PATH).
# This shared document stays unchanged until live roles retarget
# (PLAT-52 phase 2) and PermissionsBoundaryUsageCount reaches 0.
# Do not add statements here. Remaining SAM stacks: create
# seahaven-lambda-execution-boundary-<stack> below and append its ARN
# to SamCfnIamManagementPolicy only (WIDENING PATH). New HCP stacks
# do not append here. This shared document stays unchanged until live
# roles retarget (PLAT-52 phase 2) and PermissionsBoundaryUsageCount
# reaches 0.
# ---------------------------------------------------------------------------
# Per-workload Lambda execution boundaries (PLAT-52 phase 1)
#

View file

@ -0,0 +1,162 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateTfManagedRoleWithBoundary",
"Effect": "Allow",
"Action": "iam:CreateRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"Condition": {
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary*"
]
}
}
},
{
"Sid": "CreateHcptfWorkspaceRoles",
"Effect": "Allow",
"Action": "iam:CreateRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/hcptf-*"
},
{
"Sid": "CreateTfManagedPolicies",
"Effect": "Allow",
"Action": [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:TagPolicy",
"iam:UntagPolicy"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/*"
},
{
"Sid": "MutateTfManagedRolesWithBoundary",
"Effect": "Allow",
"Action": [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"Condition": {
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary*"
]
}
}
},
{
"Sid": "WriteTfManagedRoles",
"Effect": "Allow",
"Action": [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*"
},
{
"Sid": "WriteHcptfWorkspaceRoles",
"Effect": "Allow",
"Action": [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/hcptf-*"
},
{
"Sid": "PassTfManagedRolesToLambda",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
},
{
"Sid": "IamReadOnly",
"Effect": "Allow",
"Action": [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoles"
],
"Resource": "*"
},
{
"Sid": "DenyProtectedPrincipals",
"Effect": "Deny",
"Action": [
"iam:AttachRolePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteRolePermissionsBoundary",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription"
],
"Resource": [
"arn:aws:iam::__ACCOUNT_ID__:role/githubdeploy-*",
"arn:aws:iam::__ACCOUNT_ID__:role/github-cfn-execution-role",
"arn:aws:iam::__ACCOUNT_ID__:role/cdk-hnb659fds-*",
"arn:aws:iam::__ACCOUNT_ID__:role/OrganizationAccountAccessRole",
"arn:aws:iam::__ACCOUNT_ID__:role/seahaven-*"
]
},
{
"Sid": "DenyBoundaryTampering",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": [
"arn:aws:iam::__ACCOUNT_ID__:role/*",
"arn:aws:iam::__ACCOUNT_ID__:user/*"
]
},
{
"Sid": "DenySeahavenPolicyEdit",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-*"
}
]
}

View file

@ -0,0 +1,28 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RefreshIamRoles",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListRolePolicies"
],
"Resource": [
"arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"arn:aws:iam::__ACCOUNT_ID__:role/hcptf-*"
]
},
{
"Sid": "RefreshManagedPolicies",
"Effect": "Allow",
"Action": [
"iam:GetPolicy",
"iam:GetPolicyVersion"
],
"Resource": "*"
}
]
}

View file

@ -0,0 +1,137 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyCreatePolicy",
"Effect": "Deny",
"Action": [
"iam:CreatePolicy",
"iam:CreatePolicyVersion"
],
"Resource": "*"
},
{
"Sid": "CreateExecRoleWithBoundary",
"Effect": "Allow",
"Action": "iam:CreateRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*",
"Condition": {
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/__STACK_PREFIX__*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary-__STACK_NAME__"
]
}
}
},
{
"Sid": "MutateExecRoleWithBoundary",
"Effect": "Allow",
"Action": [
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*",
"Condition": {
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/__STACK_PREFIX__*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary-__STACK_NAME__"
]
}
}
},
{
"Sid": "WriteExecRoles",
"Effect": "Allow",
"Action": [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*"
},
{
"Sid": "PassExecRolesToLambda",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
},
{
"Sid": "IamReadOnly",
"Effect": "Allow",
"Action": [
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListPolicies",
"iam:ListPolicyVersions",
"iam:ListRolePolicies",
"iam:ListRoles"
],
"Resource": "*"
},
{
"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": [
"arn:aws:iam::__ACCOUNT_ID__:role/hcptf-*",
"arn:aws:iam::__ACCOUNT_ID__:role/github-cfn-execution-role",
"arn:aws:iam::__ACCOUNT_ID__:role/githubdeploy-*",
"arn:aws:iam::__ACCOUNT_ID__:role/cdk-hnb659fds-*",
"arn:aws:iam::__ACCOUNT_ID__:role/OrganizationAccountAccessRole",
"arn:aws:iam::__ACCOUNT_ID__:role/seahaven-*"
]
},
{
"Sid": "DenyBoundaryTampering",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": [
"arn:aws:iam::__ACCOUNT_ID__:role/*",
"arn:aws:iam::__ACCOUNT_ID__:user/*"
]
},
{
"Sid": "DenyBoundaryPolicyEdit",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-*"
}
]
}

View file

@ -0,0 +1,18 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::__ACCOUNT_ID__:oidc-provider/app.terraform.io"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"app.terraform.io:aud": "aws.workload.identity",
"app.terraform.io:sub": "organization:seahaven:project:__HCP_PROJECT__:workspace:iam-bootstrap:run_phase:apply"
}
}
}
]
}

View file

@ -0,0 +1,18 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::__ACCOUNT_ID__:oidc-provider/app.terraform.io"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"app.terraform.io:aud": "aws.workload.identity",
"app.terraform.io:sub": "organization:seahaven:project:__HCP_PROJECT__:workspace:iam-bootstrap:run_phase:plan"
}
}
}
]
}

View file

@ -251,6 +251,21 @@ export class OrgGovernanceStack extends cdk.Stack {
});
retain(protectSecurity);
// Prod/nonprod privileged-role lock (PLAT-145). Same Sid as security-guardrails,
// plus hcptf-bootstrap*. The HCP general apply/plan pair is CLI-owned and is
// the factory for first apply; this SCP is the standing control that keeps a
// compromised workspace from rewriting those roles. Not attached to
// external-dev (PLAT-148). Exempt principals match the security-OU copy.
const protectPrivilegedRoles = new organizations.CfnPolicy(this, "ProtectPrivilegedRoles", {
name: "protect-privileged-roles",
type: "SERVICE_CONTROL_POLICY",
description:
"prod/nonprod: protect break-glass, CDK exec, githubdeploy, and hcptf-bootstrap roles",
targetIds: [prodOu.attrId, nonprodOu.attrId],
content: scpContent("protect-privileged-roles"),
});
retain(protectPrivilegedRoles);
// Guardrails specific to the delegated-security-admin OU (SEC-BASE-C):
// the security account is the org's highest-blast-radius member, so it
// gets the external-dev-style IAM guardrails plus protection of its
@ -312,6 +327,7 @@ export class OrgGovernanceStack extends cdk.Stack {
"arn:aws:iam::*:role/githubdeploy-*",
"arn:aws:iam::*:role/seahaven-security-config-*",
"arn:aws:iam::*:role/aws-service-role/*",
"arn:aws:iam::*:role/hcptf-bootstrap*",
],
Condition: {
ArnNotLike: {

View file

@ -0,0 +1,36 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectPrivilegedRoles",
"Effect": "Deny",
"Action": [
"iam:UpdateAssumeRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:DeleteRole",
"iam:UpdateRole",
"iam:TagRole",
"iam:UntagRole"
],
"Resource": [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/cdk-hnb659fds-*",
"arn:aws:iam::*:role/githubdeploy-*",
"arn:aws:iam::*:role/seahaven-security-config-*",
"arn:aws:iam::*:role/aws-service-role/*",
"arn:aws:iam::*:role/hcptf-bootstrap*"
],
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/cdk-hnb659fds-*"
]
}
}
}
]
}

View file

@ -53,15 +53,14 @@ export interface TerraformSubstrateStackProps extends cdk.StackProps {
}
/**
* Per-account HCP Terraform deploy substrate: the conditional
* app.terraform.io OIDC provider, the prod/dev shared boundary-gated IAM
* manager, and reviewed per-workspace role pairs. External-dev conditions out
* the shared manager and uses exact inline policies for its SHOC import roles.
* Per-account HCP Terraform deploy substrate. Prod/dev still carry the
* shared seahaven-hcptf-iam-management policy and the eight existing
* hcptf-<stack> pairs until PLAT-147 deletes those stacks. New prod/dev
* per-workspace IAM is not added here: app Terraform owns it, bootstrapped
* by the CLI-owned hcptf-bootstrap pair (PLAT-144/PLAT-145).
*
* 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.
* External-dev conditions out the shared manager and uses exact inline
* policies for SHOC import roles. That IAM stays in this repo (PLAT-148).
*
* The IAM guardrail statements DERIVE FROM seahaven-cfn-exec-iam-management in
* lib/deploy-substrate/deploy-substrate.template.yaml but are deliberately

View file

@ -57,49 +57,29 @@ Description: >-
# 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: acceptable boundary ARNs are an enumerated StringEquals list
# (PLAT-52): seahaven-lambda-execution-boundary plus each
# seahaven-lambda-execution-boundary-<workload>. The policies live in
# seahaven-deploy-substrate 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 two files' allow-lists
# MUST match (mechanical sorted-JSON compare). Renaming the SHARED policy
# still fails silently. Adding a workload appends one ARN here AND creates
# the named policy in deploy-substrate. Do not use ArnLike on
# seahaven-lambda-execution-boundary-*: the org-baseline deploy role can
# CreatePolicy via CFN. INFRA-186 changed the shared boundary's CONTENT, not
# its ARN. A migrating stack that creates Lambda execution roles must add
# seahaven-lambda-execution-boundary-<stack> per the WIDENING PATH in
# lib/deploy-substrate/deploy-substrate.template.yaml, deployed before its
# first apply (README migration checklist step 3). Do not widen the shared
# document.
# COUPLING (frozen, PLAT-143/PLAT-149): the enumerated StringEquals list below
# is the last prod/dev HCP allow-list this document will carry. Do not append
# another seahaven-lambda-execution-boundary-<workload> ARN here. New HCP
# Lambda ceilings are policy/tf-managed/<stack> created by hcptf-bootstrap
# (CLI, PLAT-145). CreatePolicy lives only on that bootstrap role. Do not
# put ArnLike on this list, and do not add ArnLike to the SAM copy in
# deploy-substrate (PLAT-52 AC1: githubdeploy-seahaven-org-baseline can
# CreatePolicy via CFN). Existing eight workloads keep these ARNs until their
# consumer Terraform imports detach seahaven-hcptf-iam-management and this
# stack is deleted in prod/dev (PLAT-147). External-dev SHOC roles below do
# not attach this policy.
#
# SIZE BUDGET: an attached managed policy document is capped at 6,144
# characters (whitespace excluded). The statement set below was ~2.5 KB
# before the PLAT-52 allow-list. Each extra boundary ARN is copied into
# four Sids. Measure before merging —
# 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. Compact size recorded after
# synth with 6 ARNs: 4693 characters / 10 statements (1451 headroom).
# PLAT-76 added a seventh ARN. PLAT-120 adds an eighth (paychex-integrations,
# ~380 characters across four Sids). Re-measure after deploy.
# SIZE BUDGET: this document is at the 6,144-character wall (4693 compact /
# 10 statements after eight workload ARNs). That accumulator is why prod/dev
# per-workspace IAM is leaving this file. Do not grow it.
#
# 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. An account
# with incompatible guardrails may use a reviewed, exact inline policy
# instead, as the external-dev SHOC import roles do below.
# 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.
# PER-WORKSPACE ROLE ACCUMULATOR — CLOSED FOR PROD/DEV
# Do not append new hcptf-<stack> pairs for prod or dev. App Terraform owns
# those roles (PLAT-144/PLAT-146). The eight existing prod pairs stay here
# with DeletionPolicy: Retain until each is imported, then a Retain-remove
# update forgets them, then the prod/dev stacks delete (PLAT-147). External-dev
# SHOC roles below remain in this template (PLAT-148). All remaining subs are
# exact StringEquals (never StringLike, never a wildcarded run_phase).
#
# This template is deployed via lib/terraform-substrate-stack.ts
# (cloudformation-include) as stack seahaven-terraform-substrate, once per
@ -443,6 +423,8 @@ Resources:
HcptfAfiBackupMonitorPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-afi-backup-monitor-plan
AssumeRolePolicyDocument:
@ -511,6 +493,8 @@ Resources:
HcptfAfiBackupMonitorApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-afi-backup-monitor
AssumeRolePolicyDocument:
@ -596,6 +580,8 @@ Resources:
HcptfFrontIntegrationsPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-front-integrations-plan
AssumeRolePolicyDocument:
@ -681,6 +667,8 @@ Resources:
HcptfFrontIntegrationsApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-front-integrations
AssumeRolePolicyDocument:
@ -779,6 +767,8 @@ Resources:
HcptfPaychexIntegrationsPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-paychex-integrations-plan
AssumeRolePolicyDocument:
@ -869,6 +859,8 @@ Resources:
HcptfPaychexIntegrationsApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-paychex-integrations
AssumeRolePolicyDocument:
@ -993,6 +985,8 @@ Resources:
HcptfShOpensweTracesPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-sh-openswe-traces-plan
AssumeRolePolicyDocument:
@ -1062,6 +1056,8 @@ Resources:
HcptfShOpensweTracesApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-sh-openswe-traces
AssumeRolePolicyDocument:
@ -1203,6 +1199,8 @@ Resources:
HcptfProcurementIngestPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-procurement-ingest-plan
AssumeRolePolicyDocument:
@ -1392,6 +1390,8 @@ Resources:
HcptfProcurementIngestApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-procurement-ingest
AssumeRolePolicyDocument:
@ -1656,6 +1656,8 @@ Resources:
HcptfSeahavenSitePlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-site-plan
AssumeRolePolicyDocument:
@ -1745,6 +1747,8 @@ Resources:
HcptfSeahavenSiteApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-site
AssumeRolePolicyDocument:
@ -1841,6 +1845,8 @@ Resources:
HcptfMealOrderManagerPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-meal-order-manager-plan
AssumeRolePolicyDocument:
@ -1976,6 +1982,8 @@ Resources:
HcptfMealOrderManagerApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-meal-order-manager
AssumeRolePolicyDocument:
@ -2194,6 +2202,8 @@ Resources:
MealOrderApiAccessLogResourcePolicy:
Type: AWS::Logs::ResourcePolicy
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
PolicyName: MealOrderManagerApiAccessLogDelivery
PolicyDocument: !Sub |
@ -2229,6 +2239,8 @@ Resources:
HcptfDoorUnlockApiPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-door-unlock-api-plan
AssumeRolePolicyDocument:
@ -2351,6 +2363,8 @@ Resources:
HcptfDoorUnlockApiApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
RoleName: hcptf-seahaven-door-unlock-api
AssumeRolePolicyDocument:
@ -2533,6 +2547,8 @@ Resources:
DoorUnlockApiAccessLogResourcePolicy:
Type: AWS::Logs::ResourcePolicy
Condition: IsProdAccount
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
PolicyName: DoorUnlockApiAccessLogDelivery
PolicyDocument: !Sub |

View file

@ -0,0 +1,275 @@
#!/usr/bin/env bash
#
# create-hcptf-bootstrap-roles.sh — create the out-of-band HCP general apply/plan
# pair in seahaven-prod and seahaven-dev (PLAT-145). Not a CDK stack. Not
# external-dev.
#
# Prerequisites:
# * org-governance has deployed protect-privileged-roles to prod/nonprod
# (hcptf-bootstrap* is SCP-protected). Do not create the roles first.
# * Caller can sts:AssumeRole OrganizationAccountAccessRole in the target.
# * The account already has oidc-provider/app.terraform.io (Retain).
#
# Usage:
# scripts/create-hcptf-bootstrap-roles.sh --account prod|dev [--dry-run]
# scripts/create-hcptf-bootstrap-roles.sh --account prod --simulate
# scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace STACK-prod
#
# --simulate runs iam:SimulatePrincipalPolicy against the apply role. Requires
# the role to already exist.
#
# Default trust is exact StringEquals for workspace iam-bootstrap only.
# --allow-workspace NAME adds one extra exact sub for that HCP workspace
# (first-apply / import window). Re-run with no --allow-workspace to pin
# trust back to iam-bootstrap only. Never StringLike. SCP blocks
# hcptf-bootstrap from updating its own trust; this script assumes OAA.
#
# After create: HCP workspace iam-bootstrap in project seahaven-<env>, Manual
# apply, workspace-level TFC_AWS_*_ROLE_ARN only (never a project variable set).
#
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPL="$ROOT/lib/hcptf-bootstrap"
ACCOUNT_KEY=""
DRY_RUN=0
SIMULATE=0
ALLOW_WORKSPACE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--account) ACCOUNT_KEY="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--simulate) SIMULATE=1; shift ;;
--allow-workspace) ALLOW_WORKSPACE="$2"; shift 2 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) echo "unexpected argument: $1" >&2; exit 2 ;;
esac
done
case "$ACCOUNT_KEY" in
prod)
ACCOUNT_ID="011934824531"
HCP_PROJECT="seahaven-prod"
;;
dev)
ACCOUNT_ID="710827005802"
HCP_PROJECT="seahaven-dev"
;;
*)
echo "usage: $0 --account prod|dev [--dry-run] [--simulate] [--allow-workspace NAME]" >&2
exit 2
;;
esac
if [[ -n "$ALLOW_WORKSPACE" ]]; then
if [[ "$ALLOW_WORKSPACE" == "iam-bootstrap" ]]; then
echo "--allow-workspace iam-bootstrap is the default; omit the flag" >&2
exit 2
fi
if [[ ! "$ALLOW_WORKSPACE" =~ ^[a-z0-9]([a-z0-9-]{0,88}[a-z0-9])?$ ]]; then
echo "invalid --allow-workspace '$ALLOW_WORKSPACE' (lowercase kebab, no wildcards)" >&2
exit 2
fi
fi
render_to() {
local src="$1"
local dest="$2"
sed -e "s/__ACCOUNT_ID__/${ACCOUNT_ID}/g" -e "s/__HCP_PROJECT__/${HCP_PROJECT}/g" "$src" > "$dest"
}
# Render a trust template. Optional extra workspace becomes a second exact
# StringEquals sub (OR). Does not use StringLike.
render_trust() {
local src="$1"
local dest="$2"
python3 - "$src" "$dest" "$ACCOUNT_ID" "$HCP_PROJECT" "$ALLOW_WORKSPACE" <<'PY'
import json, pathlib, sys
src, dest, account, project, extra = sys.argv[1:6]
text = pathlib.Path(src).read_text().replace("__ACCOUNT_ID__", account).replace("__HCP_PROJECT__", project)
data = json.loads(text)
if extra:
cond = data["Statement"][0]["Condition"]["StringEquals"]
sub = cond["app.terraform.io:sub"]
if isinstance(sub, str):
sub = [sub]
phase = "apply" if ":run_phase:apply" in sub[0] else "plan"
added = f"organization:seahaven:project:{project}:workspace:{extra}:run_phase:{phase}"
if added not in sub:
sub.append(added)
cond["app.terraform.io:sub"] = sub
pathlib.Path(dest).write_text(json.dumps(data, indent=2) + "\n")
PY
}
ORIG_AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID-}"
ORIG_AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY-}"
ORIG_AWS_SESSION_TOKEN="${AWS_SESSION_TOKEN-}"
restore_creds() {
if [[ -n "${ORIG_AWS_ACCESS_KEY_ID}" ]]; then
export AWS_ACCESS_KEY_ID="$ORIG_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$ORIG_AWS_SECRET_ACCESS_KEY"
export AWS_SESSION_TOKEN="$ORIG_AWS_SESSION_TOKEN"
else
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
fi
}
trap restore_creds EXIT
CREDS="$(aws sts assume-role \
--role-arn "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
--role-session-name plat-145-hcptf-bootstrap \
--query Credentials --output json)"
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
export AWS_SESSION_TOKEN
AWS_ACCESS_KEY_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["AccessKeyId"])' <<<"$CREDS")"
AWS_SECRET_ACCESS_KEY="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SecretAccessKey"])' <<<"$CREDS")"
AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SessionToken"])' <<<"$CREDS")"
echo "account: ${ACCOUNT_ID} (${ACCOUNT_KEY})"
echo "caller: $(aws sts get-caller-identity --query Arn --output text)"
if [[ "$SIMULATE" -eq 1 ]]; then
APPLY_ARN="arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap"
echo "== simulate ${APPLY_ARN} =="
echo "-- CreateRole with tf-managed boundary (expect allowed) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/tf-managed/example" \
--context-entries 'ContextKeyName=iam:PermissionsBoundary,ContextKeyValues=arn:aws:iam::'"${ACCOUNT_ID}"':policy/tf-managed/example,ContextKeyType=string' \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- CreateRole with AdministratorAccess boundary (expect implicitDeny) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/tf-managed/example" \
--context-entries 'ContextKeyName=iam:PermissionsBoundary,ContextKeyValues=arn:aws:iam::aws:policy/AdministratorAccess,ContextKeyType=string' \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- CreatePolicy on tf-managed (expect allowed) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreatePolicy \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:policy/tf-managed/example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- PutRolePolicy on hcptf-* (expect allowed; import/first-apply path) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:PutRolePolicy iam:DetachRolePolicy \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/hcptf-example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole iam:PutRolePolicy iam:DeleteRole \
--resource-arns \
"arn:aws:iam::${ACCOUNT_ID}:role/githubdeploy-example" \
"arn:aws:iam::${ACCOUNT_ID}:role/cdk-hnb659fds-example" \
"arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
--query 'EvaluationResults[].{Action:EvalActionName,Resource:EvalResourceName,Decision:EvalDecision}' \
--output table
exit 0
fi
WORKDIR="$(mktemp -d)"
cleanup() {
rm -rf "${WORKDIR:-}"
restore_creds
}
trap cleanup EXIT
render_trust "$TMPL/trust-apply.json.tmpl" "$WORKDIR/trust-apply.json"
render_trust "$TMPL/trust-plan.json.tmpl" "$WORKDIR/trust-plan.json"
render_to "$TMPL/apply-policy.json.tmpl" "$WORKDIR/apply-policy.json"
render_to "$TMPL/plan-refresh-policy.json.tmpl" "$WORKDIR/plan-refresh.json"
if [[ -n "$ALLOW_WORKSPACE" ]]; then
echo "trust extra workspace: ${ALLOW_WORKSPACE} (exact StringEquals; re-run without this flag to revoke)"
else
echo "trust: iam-bootstrap only"
fi
python3 - "$WORKDIR" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
for p in root.glob("*.json"):
data = json.loads(p.read_text())
dump = json.dumps(data)
if p.name.startswith("trust-"):
if "StringLike" in dump:
raise SystemExit(f"{p.name}: trust must stay StringEquals")
subs = data["Statement"][0]["Condition"]["StringEquals"]["app.terraform.io:sub"]
if isinstance(subs, str):
subs = [subs]
for s in subs:
if "*" in s or "?" in s:
raise SystemExit(f"{p.name}: wildcard in sub {s}")
if p.name == "apply-policy.json":
if '"Null"' in dump:
raise SystemExit("apply-policy must not use Null on PermissionsBoundary")
if "AdministratorAccess" in dump:
raise SystemExit("apply-policy must not name AdministratorAccess")
PY
create_or_update_role() {
local name="$1"
local trust_file="$2"
if aws iam get-role --role-name "$name" >/dev/null 2>&1; then
echo " $name: exists, updating trust"
if [[ "$DRY_RUN" -eq 0 ]]; then
aws iam update-assume-role-policy --role-name "$name" --policy-document "file://${trust_file}"
fi
else
echo " $name: create"
if [[ "$DRY_RUN" -eq 0 ]]; then
aws iam create-role \
--role-name "$name" \
--assume-role-policy-document "file://${trust_file}" \
--description "HCP Terraform ${name} (PLAT-145). Console/CLI owned. Manual apply only." \
--tags Key=Project,Value=hcp-bootstrap Key=Owner,Value=adam@seahavenind.com Key=ManagedBy,Value=cli
fi
fi
}
echo "== roles =="
create_or_update_role hcptf-bootstrap "$WORKDIR/trust-apply.json"
create_or_update_role hcptf-bootstrap-plan "$WORKDIR/trust-plan.json"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "dry-run: skipping PutRolePolicy / AttachRolePolicy"
exit 0
fi
aws iam put-role-policy \
--role-name hcptf-bootstrap \
--policy-name hcptf-bootstrap-iam-factory \
--policy-document "file://${WORKDIR}/apply-policy.json"
echo " hcptf-bootstrap: put inline hcptf-bootstrap-iam-factory"
aws iam put-role-policy \
--role-name hcptf-bootstrap-plan \
--policy-name hcptf-bootstrap-plan-refresh \
--policy-document "file://${WORKDIR}/plan-refresh.json"
echo " hcptf-bootstrap-plan: put inline hcptf-bootstrap-plan-refresh"
aws iam attach-role-policy \
--role-name hcptf-bootstrap-plan \
--policy-arn arn:aws:iam::aws:policy/job-function/ViewOnlyAccess \
2>/dev/null || true
echo " hcptf-bootstrap-plan: attached ViewOnlyAccess"
echo "done. Next: HCP workspace iam-bootstrap in ${HCP_PROJECT}, Manual apply,"
echo " TFC_AWS_APPLY_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap"
echo " TFC_AWS_PLAN_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap-plan"
if [[ -n "$ALLOW_WORKSPACE" ]]; then
echo "First-apply/import window: point workspace ${ALLOW_WORKSPACE} TFC_AWS_* at the pair above,"
echo " apply, retarget scoped ARNs, then re-run this script with no --allow-workspace."
fi
echo "Then: $0 --account ${ACCOUNT_KEY} --simulate"

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
#
# delete-terraform-substrate-prod-dev.sh — PLAT-147 live delete of
# seahaven-terraform-substrate in prod (011934824531) and/or dev (710827005802).
# Does not touch terraform-substrate-external-dev (396287094661).
#
# Gate: every prod/dev hcptf-* apply role MUST already have detached
# seahaven-hcptf-iam-management (consumer import PRs). OIDC is Retain and
# survives. The guardrail policy is deleted with the stack.
#
# Do not run until:
# 1. Each of the eight prod apply roles is imported in app Terraform.
# 2. A substrate update with DeletionPolicy: Retain has removed those roles
# from the template (CFN forgets them without deleting).
# 3. terraform-substrate-prod and terraform-substrate-dev are removed from
# bin/app.ts and .github/workflows/deploy.yaml so CD cannot recreate them.
#
# Usage:
# scripts/delete-terraform-substrate-prod-dev.sh --account prod|dev --inventory
# scripts/delete-terraform-substrate-prod-dev.sh --account prod|dev --yes
#
set -euo pipefail
ACCOUNT_KEY=""
INVENTORY=0
ASSUME_YES=0
while [[ $# -gt 0 ]]; do
case "$1" in
--account) ACCOUNT_KEY="$2"; shift 2 ;;
--inventory) INVENTORY=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) echo "unexpected argument: $1" >&2; exit 2 ;;
esac
done
case "$ACCOUNT_KEY" in
prod) ACCOUNT_ID="011934824531" ;;
dev) ACCOUNT_ID="710827005802" ;;
*)
echo "usage: $0 --account prod|dev [--inventory|--yes]" >&2
exit 2
;;
esac
if [[ "$ACCOUNT_ID" == "396287094661" ]]; then
echo "refusing: external-dev is out of scope (PLAT-148)" >&2
exit 2
fi
ORIG_AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID-}"
ORIG_AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY-}"
ORIG_AWS_SESSION_TOKEN="${AWS_SESSION_TOKEN-}"
restore_creds() {
if [[ -n "${ORIG_AWS_ACCESS_KEY_ID}" ]]; then
export AWS_ACCESS_KEY_ID="$ORIG_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$ORIG_AWS_SECRET_ACCESS_KEY"
export AWS_SESSION_TOKEN="$ORIG_AWS_SESSION_TOKEN"
else
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
fi
}
trap restore_creds EXIT
CREDS="$(aws sts assume-role \
--role-arn "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
--role-session-name plat-147-tf-substrate \
--query Credentials --output json)"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
AWS_ACCESS_KEY_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["AccessKeyId"])' <<<"$CREDS")"
AWS_SECRET_ACCESS_KEY="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SecretAccessKey"])' <<<"$CREDS")"
AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SessionToken"])' <<<"$CREDS")"
echo "account: ${ACCOUNT_ID} (${ACCOUNT_KEY})"
echo "caller: $(aws sts get-caller-identity --query Arn --output text)"
POLICY_ARN="arn:aws:iam::${ACCOUNT_ID}:policy/seahaven-hcptf-iam-management"
ATTACHED="[]"
if aws iam get-policy --policy-arn "$POLICY_ARN" >/dev/null 2>&1; then
ATTACHED="$(aws iam list-entities-for-policy --policy-arn "$POLICY_ARN" --query 'PolicyRoles[].RoleName' --output json)"
fi
echo "seahaven-hcptf-iam-management attachments: $ATTACHED"
HCPS="$(aws iam list-roles --query 'Roles[?starts_with(RoleName, `hcptf-`)].RoleName' --output json)"
echo "hcptf-* roles still present: $HCPS"
OIDC="$(aws iam list-open-id-connect-providers --query 'OpenIDConnectProviderList[?contains(Arn, `app.terraform.io`)].Arn' --output json)"
echo "app.terraform.io OIDC: $OIDC"
if [[ "$INVENTORY" -eq 1 ]]; then
exit 0
fi
python3 - "$ATTACHED" <<'PY'
import json, sys
roles = json.loads(sys.argv[1])
if roles:
raise SystemExit(f"refusing delete: seahaven-hcptf-iam-management still attached to {roles}")
PY
if [[ "$ASSUME_YES" -eq 0 ]]; then
read -r -p "delete CloudFormation stack seahaven-terraform-substrate in ${ACCOUNT_ID}? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || { echo "skipped"; exit 0; }
fi
echo "deleting seahaven-terraform-substrate (OIDC DeletionPolicy=Retain)"
aws cloudformation delete-stack --stack-name seahaven-terraform-substrate
aws cloudformation wait stack-delete-complete --stack-name seahaven-terraform-substrate
echo "stack gone. confirm OIDC still exists:"
aws iam list-open-id-connect-providers --query 'OpenIDConnectProviderList[?contains(Arn, `app.terraform.io`)].Arn' --output text