mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-10-02 22:43:13 +00:00
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:
parent
35dc61b806
commit
b02f52b805
15 changed files with 1349 additions and 251 deletions
339
README.md
339
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-<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)
|
||||
|
||||
|
|
|
|||
20
bin/app.ts
20
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-<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",
|
||||
|
|
|
|||
264
examples/hcptf-workspace-iam/hcp_iam.tf.example
Normal file
264
examples/hcptf-workspace-iam/hcp_iam.tf.example
Normal 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"
|
||||
# }
|
||||
|
|
@ -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)
|
||||
#
|
||||
|
|
|
|||
162
lib/hcptf-bootstrap/apply-policy.json.tmpl
Normal file
162
lib/hcptf-bootstrap/apply-policy.json.tmpl
Normal 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-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
lib/hcptf-bootstrap/plan-refresh-policy.json.tmpl
Normal file
28
lib/hcptf-bootstrap/plan-refresh-policy.json.tmpl
Normal 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": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
137
lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl
Normal file
137
lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl
Normal 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-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
lib/hcptf-bootstrap/trust-apply.json.tmpl
Normal file
18
lib/hcptf-bootstrap/trust-apply.json.tmpl
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
lib/hcptf-bootstrap/trust-plan.json.tmpl
Normal file
18
lib/hcptf-bootstrap/trust-plan.json.tmpl
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
36
lib/scp/protect-privileged-roles.json
Normal file
36
lib/scp/protect-privileged-roles.json
Normal 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-*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
275
scripts/create-hcptf-bootstrap-roles.sh
Executable file
275
scripts/create-hcptf-bootstrap-roles.sh
Executable 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"
|
||||
111
scripts/delete-terraform-substrate-prod-dev.sh
Executable file
111
scripts/delete-terraform-substrate-prod-dev.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue