diff --git a/README.md b/README.md index 61b74e2..09ea358 100644 --- a/README.md +++ b/README.md @@ -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-` pairs, Lambda exec +roles, `policy/tf-managed/` ceilings) in the console. Do not append +new prod/dev `hcptf-` 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-` 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-` apply + `hcptf--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** -(`-`, 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. +(`-`, 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-: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-`:** trust pinned to that stack's + workspace and `run_phase:apply`. No `seahaven-hcptf-iam-management`. May + manage `role/tf-managed/-*` with `iam:PermissionsBoundary` + `StringLike` this stack's `policy/tf-managed/*`, the shared + `seahaven-lambda-execution-boundary`, or + `seahaven-lambda-execution-boundary-`. May not `CreatePolicy` / + `CreatePolicyVersion`. May not `UpdateAssumeRolePolicy`, `DeleteRole`, or + `PutRolePolicy` on `hcptf-*` (including itself). Need more apply-role + permission later: `--allow-workspace -` 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 (`-`). 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--plan` and - `hcptf-` (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-: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-`** 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/-*`; - `events:DescribeRule` (and list-targets/tags as needed) on - `rule/-*`; `lambda:*` (or at least the Get*/List* the provider - uses) on `function:-*` / `layer:-*`; `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:-*` / - `layer:-*`, `events:*` on `rule/-*`, 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-` 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-` 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-` 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 `-` 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 -` (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-`: 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-` 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) diff --git a/bin/app.ts b/bin/app.ts index 0ed3034..3f37371 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -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- 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", diff --git a/examples/hcptf-workspace-iam/hcp_iam.tf.example b/examples/hcptf-workspace-iam/hcp_iam.tf.example new file mode 100644 index 0000000..614ad42 --- /dev/null +++ b/examples/hcptf-workspace-iam/hcp_iam.tf.example @@ -0,0 +1,264 @@ +# Example: import an existing prod/dev hcptf- 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" +# } diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml index 0b31b59..47325f5 100644 --- a/lib/deploy-substrate/deploy-substrate.template.yaml +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -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- 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- 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/ 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/ 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- - # 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- 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) # diff --git a/lib/hcptf-bootstrap/apply-policy.json.tmpl b/lib/hcptf-bootstrap/apply-policy.json.tmpl new file mode 100644 index 0000000..ce64504 --- /dev/null +++ b/lib/hcptf-bootstrap/apply-policy.json.tmpl @@ -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-*" + } + ] +} diff --git a/lib/hcptf-bootstrap/plan-refresh-policy.json.tmpl b/lib/hcptf-bootstrap/plan-refresh-policy.json.tmpl new file mode 100644 index 0000000..5867694 --- /dev/null +++ b/lib/hcptf-bootstrap/plan-refresh-policy.json.tmpl @@ -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": "*" + } + ] +} diff --git a/lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl b/lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl new file mode 100644 index 0000000..284903f --- /dev/null +++ b/lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl @@ -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-*" + } + ] +} diff --git a/lib/hcptf-bootstrap/trust-apply.json.tmpl b/lib/hcptf-bootstrap/trust-apply.json.tmpl new file mode 100644 index 0000000..6e82213 --- /dev/null +++ b/lib/hcptf-bootstrap/trust-apply.json.tmpl @@ -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" + } + } + } + ] +} diff --git a/lib/hcptf-bootstrap/trust-plan.json.tmpl b/lib/hcptf-bootstrap/trust-plan.json.tmpl new file mode 100644 index 0000000..468f89c --- /dev/null +++ b/lib/hcptf-bootstrap/trust-plan.json.tmpl @@ -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" + } + } + } + ] +} diff --git a/lib/org-governance-stack.ts b/lib/org-governance-stack.ts index a2ed2c2..5a9f902 100644 --- a/lib/org-governance-stack.ts +++ b/lib/org-governance-stack.ts @@ -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: { diff --git a/lib/scp/protect-privileged-roles.json b/lib/scp/protect-privileged-roles.json new file mode 100644 index 0000000..202ec22 --- /dev/null +++ b/lib/scp/protect-privileged-roles.json @@ -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-*" + ] + } + } + } + ] +} diff --git a/lib/terraform-substrate-stack.ts b/lib/terraform-substrate-stack.ts index 7b54179..f35a0c3 100644 --- a/lib/terraform-substrate-stack.ts +++ b/lib/terraform-substrate-stack.ts @@ -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- 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- / hcptf--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 diff --git a/lib/terraform-substrate/terraform-substrate.template.yaml b/lib/terraform-substrate/terraform-substrate.template.yaml index cbe4add..17f5144 100644 --- a/lib/terraform-substrate/terraform-substrate.template.yaml +++ b/lib/terraform-substrate/terraform-substrate.template.yaml @@ -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-. 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- 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- ARN here. New HCP +# Lambda ceilings are policy/tf-managed/ 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--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. 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- 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 | diff --git a/scripts/create-hcptf-bootstrap-roles.sh b/scripts/create-hcptf-bootstrap-roles.sh new file mode 100755 index 0000000..09b4cbf --- /dev/null +++ b/scripts/create-hcptf-bootstrap-roles.sh @@ -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-, 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" diff --git a/scripts/delete-terraform-substrate-prod-dev.sh b/scripts/delete-terraform-substrate-prod-dev.sh new file mode 100755 index 0000000..75c872f --- /dev/null +++ b/scripts/delete-terraform-substrate-prod-dev.sh @@ -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