feat(iam): add external-dev backend Terraform substrate (#131)
Some checks are pending
Deploy / deploy-management (push) Waiting to run
Deploy / deploy-external-dev (push) Waiting to run
Deploy / deploy-security (push) Waiting to run
Deploy / deploy-dev (push) Waiting to run
Deploy / deploy-prod (push) Waiting to run

* feat(iam): add external-dev backend terraform substrate

* fix(iam): require boundaries for SHOC policy writes
This commit is contained in:
Adam Moussa 2026-08-29 21:04:40 +00:00 • committed by GitHub
parent ee233379dd
commit dba0871587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1232 additions and 23 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
node_modules/
cdk.out/
cdk.out*/
*.js
*.d.ts
*.js.map

131
README.md
View file

@ -18,7 +18,8 @@ like any other stack.
> only) until 2026-07-14, when the external-dev member baseline was merged in
> and the repo renamed. Deployed CloudFormation stack names are unchanged.
Stacks (deployed by the CD workflow — one job per target account):
Stacks (normally deployed by one CD job per target account; staged exceptions
are noted):
| Stack | Account | Region | Purpose |
|---|---|---|---|
@ -30,6 +31,7 @@ Stacks (deployed by the CD workflow — one job per target account):
| `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 until SHOC role imports complete; HCP roles/deploy boundaries for the backend rehearsal, referencing the existing OIDC provider |
| `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) |
@ -67,6 +69,7 @@ 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-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` |
| `app-web-acl-prod` | `seahaven-app-web-acl` | 011934824531 | us-east-1 | `lib/app-web-acl-stack.ts` |
@ -214,9 +217,11 @@ created with the boundary already attached.
`lib/terraform-substrate-stack.ts` + `lib/terraform-substrate/terraform-substrate.template.yaml`
deploy `seahaven-terraform-substrate` into each member account that hosts
Terraform-managed workloads (currently seahaven-prod and seahaven-dev; never
mgmt — mgmt stays SAM until its stacks migrate out). It contains only the
shared account-level plumbing:
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:
- the `app.terraform.io` OIDC identity provider (audience
`aws.workload.identity`; Retain — it is the federation anchor for every
@ -226,7 +231,19 @@ shared account-level plumbing:
`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.
/ `DenySelfMutation` backstops,
- 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
temporarily retains its live broad `elasticbeanstalk-*` S3 grants so
boundary attachment cannot regress deployment before the separately
reviewed policy narrowing,
- external-dev-only runtime boundaries
`shoc-backend-{tf-poc,dev,staging}-runtime-boundary`. These retain only the
account-scoped S3, environment health/log, and X-Ray portions of
`AWSElasticBeanstalkWebTier`, plus each environment's exact secrets/KMS/STS
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
@ -255,6 +272,110 @@ 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.
**External-dev SHOC role adoption is a staged CloudFormation import, not a
normal first deploy.** Exactly four roles exist today:
`hcptf-shoc-backend-{dev,staging}` and their `-plan` partners. The tf-poc pair
does not exist. Two independent CDK contexts make each transition explicit:
`enableShocBackendPocRoles` and `enableShocBackendLiveRoles`. Both are
version-controlled as `false` in `cdk.json` for the initial rollout.
`terraform-substrate-external-dev` is deliberately absent from the automatic
external-dev deploy job during this sequence; `external-dev-baseline` remains
automatic and unchanged.
1. Create the role-free base stack:
```bash
npx cdk deploy terraform-substrate-external-dev \
-c enableShocBackendPocRoles=false \
-c enableShocBackendLiveRoles=false
```
`CreateOIDCProvider=false` is fixed in `bin/app.ts`; the external-dev
account therefore creates neither the existing provider, SHOC roles, nor
the prod/dev-only shared IAM policy. The base stack does create all six
retained external-dev deploy/runtime boundary policies.
2. Set `enableShocBackendPocRoles` to `true` in `cdk.json`, leave the live gate
`false`, review the synthesized two-role addition, then run the normal
external-dev stack update. This creates only the new tf-poc HCP plan/apply
pair. The retained POC CDK stack references
`shoc-backend-tf-poc-deploy-boundary` when it creates
`githubdeploy-shoc-backend-tf-poc` and
`shoc-backend-tf-poc-runtime-boundary` when it creates the POC runtime
role; do not attach the generic account execution boundary to either role.
3. Prove both tf-poc HCP assumptions and the retained POC import rehearsal
before touching the live-role ownership boundary.
4. In a separately approved administrator/CDK migration, tag the existing HCP
apply roles first:
`hcptf-shoc-backend-dev` gets
`HcpTerraformWorkspace=shoc-backend-dev`, and
`hcptf-shoc-backend-staging` gets
`HcpTerraformWorkspace=shoc-backend-staging`. Next attach
`shoc-backend-dev-deploy-boundary` and
`shoc-backend-staging-deploy-boundary` to the exact `githubdeploy-*` roles,
and attach `shoc-backend-dev-runtime-boundary` /
`shoc-backend-staging-runtime-boundary` to the exact runtime roles. Verify
the boundary ceilings before adding the matching manager tag to either
target `githubdeploy-*` role. The POC CDK
creates its deploy role with `HcpTerraformWorkspace=shoc-backend-tf-poc`;
the substrate-created POC apply role already carries the same principal
tag. Verify each effective deployment action before continuing. HCP remains
blocked while a target tag is missing/different or the target lacks its
exact dedicated boundary, so a partial migration cannot authorize policy
writes. Complete both runtime/deploy boundary attachments before workload
imports. HCP apply roles deliberately have no
`iam:PutRolePermissionsBoundary` or boundary-policy mutation permissions.
5. In the backend bootstrap, add Terraform `removed` blocks with
`destroy = false` for only the four dev/staging HCP roles and their inline
policies. Apply and verify Terraform state no longer owns them while all
four physical roles and ARNs remain unchanged.
6. Set both contexts to `true`, synthesize with
`npx cdk synth terraform-substrate-external-dev`, and create a
CloudFormation **IMPORT** change set for the four existing
`AWS::IAM::Role` resources by exact role name. Do not run a normal
CREATE/UPDATE change set for this ownership transition. The POC gate must
remain true so the already-managed pair stays in the template. Import
records ownership; it does not update existing role properties or inline
policies.
7. Run a separate, reviewed CloudFormation reconcile update after import and
before switching workspace credentials. Each current live role has one
inline policy: `shoc-backend-dev-import-plan`,
`shoc-backend-dev-import-apply`,
`shoc-backend-staging-import-plan`, or
`shoc-backend-staging-import-apply`. Existing descriptions and tags are
inventoried in the backend handoff. Reconcile those explicit differences
to the final baseline shape without replacing a role.
8. After reconcile and HCP assumption proof, keep both context values committed
as `true`. Every subsequent normal deployment must synthesize all six
roles. Never return either gate to false as a rollback mechanism; Retain
protects the physical role but removing it from the stack abandons
CloudFormation ownership.
9. Only after all imports/reconciliation complete and both context defaults
are permanently `true`, add `terraform-substrate-external-dev` back to the
external-dev workflow stack selector. Until then all substrate operations
are deliberate manual deploy/import actions.
10. Retire the backend bootstrap only after the POC pair and all four imported
live roles are proven under this stack. Role deletion/recreation is never a
migration step.
The external-dev apply roles intentionally omit role create/delete,
managed-policy attach/detach, trust or boundary mutation, `iam:PassRole`, and
secret-value APIs. IAM writes are limited to exact-role inline-policy and
ordinary tag updates plus exact-profile tags; role descriptions remain stable
and HCP receives no `UpdateRole` or `UpdateRoleDescription`. The SCP permits
only the three enumerated HCP apply roles to mutate a `githubdeploy-*` role
whose locked `HcpTerraformWorkspace` resource tag equals the caller's immutable
principal tag. Adding or changing that manager tag remains administrator/CDK
only. POC DNS and certificate access is tag/name constrained because their
physical IDs are allocated by the temporary retained CDK stack before
Terraform imports them. Dev and staging DNS writes are pinned to their existing
hosted-zone IDs and API record names.
Current compact policy-document sizes are 1,387 / 1,873 / 1,844 characters for
the POC/dev/staging deploy boundaries and 1,176 / 1,779 / 1,656 for their
runtime boundaries, each below IAM's 6,144-character managed-policy limit. The
external-dev IAM guardrail SCP is 4,922 compact characters against its
5,120-character Organizations limit; keep size assertions in every change.
**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**

View file

@ -31,6 +31,13 @@ const PROD_VPC_IDS = [
const app = new cdk.App();
const contextBoolean = (key: string): boolean => {
const value = app.node.tryGetContext(key);
if (value === true || value === "true") return true;
if (value === false || value === "false" || value === undefined) return false;
throw new Error(`${key} must be true or false`);
};
new AccountBaselineStack(app, "account-baseline", {
stackName: "seahaven-account-baseline",
env: { account: ACCOUNT, region: "us-east-1" },
@ -190,8 +197,10 @@ const deploySubstrateDev = new DeploySubstrateStack(app, "deploy-substrate-dev",
// 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 ONLY — mgmt stays SAM (Terraform POC
// decision 2026-07-30; the mgmt POC substrate was rolled back the same day).
// 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
// inside Condition strings, so CFN infers no creation edge — the explicit
// dependency below guarantees the deploy-substrate stack (which owns the
@ -227,6 +236,21 @@ const terraformSubstrateDev = new TerraformSubstrateStack(
);
terraformSubstrateDev.addStackDependency(deploySubstrateDev);
// External-dev already has app.terraform.io federation. Both role gates start
// false in cdk.json: POC is enabled by a normal update; dev/staging only by
// CloudFormation import after Terraform relinquishes those four live roles.
const terraformSubstrateExternalDev = new TerraformSubstrateStack(
app,
"terraform-substrate-external-dev",
{
stackName: "seahaven-terraform-substrate",
env: { account: EXTERNAL_DEV_ACCOUNT, region: "us-east-1" },
createOidcProvider: false,
enableShocBackendPocRoles: contextBoolean("enableShocBackendPocRoles"),
enableShocBackendLiveRoles: contextBoolean("enableShocBackendLiveRoles"),
},
);
// ── Shared DynamoDB CMK (INFRA-95 / M-3) ─────────────────────────────────────
// Dedicated, standalone stack so the customer-managed key for sensitive
// finance/PII DynamoDB tables is an independent shared dependency for the owning

View file

@ -17,6 +17,8 @@
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws"]
"@aws-cdk/core:target-partitions": ["aws"],
"enableShocBackendPocRoles": false,
"enableShocBackendLiveRoles": false
}
}

View file

@ -12,11 +12,18 @@
}
},
{
"Sid": "ProtectBoundaryPolicyFromEdits",
"Sid": "ProtectExecutionBoundary",
"Effect": "Deny",
"Action": ["iam:CreatePolicyVersion", "iam:SetDefaultPolicyVersion", "iam:DeletePolicy", "iam:DeletePolicyVersion"],
"Resource": "arn:aws:iam::396287094661:policy/external-dev-execution-boundary"
},
{
"Sid": "ProtectShocBoundaries",
"Effect": "Deny",
"Action": ["iam:CreatePolicyVersion", "iam:SetDefaultPolicyVersion", "iam:DeletePolicy", "iam:DeletePolicyVersion"],
"Resource": "arn:aws:iam::396287094661:policy/shoc-backend-*-boundary",
"Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*"] } }
},
{
"Sid": "DenyAlteringPermissionsBoundaries",
"Effect": "Deny",
@ -32,18 +39,61 @@
"Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole"] } }
},
{
"Sid": "ProtectPrivilegedRoles",
"Sid": "ProtectNonGithubPrivilegedRoles",
"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::396287094661:role/OrganizationAccountAccessRole",
"arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
"arn:aws:iam::396287094661:role/githubdeploy-*",
"arn:aws:iam::396287094661:role/seahaven-extdev-config-recorder-role",
"arn:aws:iam::396287094661:role/seahaven-extdev-config-custom-resource-role",
"arn:aws:iam::396287094661:role/aws-service-role/*"
],
"Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*"] } }
},
{
"Sid": "ProtectDeploymentPrincipalLifecycle",
"Effect": "Deny",
"Action": ["iam:UpdateAssumeRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:DeleteRolePolicy", "iam:DeleteRole", "iam:UpdateRole", "iam:UpdateRoleDescription", "iam:PutRolePermissionsBoundary", "iam:DeleteRolePermissionsBoundary"],
"Resource": ["arn:aws:iam::396287094661:role/githubdeploy-*", "arn:aws:iam::396287094661:role/hcptf-*"],
"Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*"] } }
},
{
"Sid": "ProtectGithubRoleMetadata",
"Effect": "Deny",
"Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"],
"Resource": "arn:aws:iam::396287094661:role/githubdeploy-*",
"Condition": { "ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] } }
},
{
"Sid": "DenyUnmanagedGithubRole",
"Effect": "Deny",
"Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"],
"Resource": "arn:aws:iam::396287094661:role/githubdeploy-*",
"Condition": {
"ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] },
"Null": { "aws:ResourceTag/HcpTerraformWorkspace": "true" }
}
},
{
"Sid": "EnforceGithubRoleManagerTag",
"Effect": "Deny",
"Action": ["iam:PutRolePolicy", "iam:TagRole", "iam:UntagRole"],
"Resource": "arn:aws:iam::396287094661:role/githubdeploy-*",
"Condition": {
"ArnLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/hcptf-shoc-backend-tf-poc", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-dev", "arn:aws:iam::396287094661:role/hcptf-shoc-backend-staging"] },
"StringNotEquals": { "aws:ResourceTag/HcpTerraformWorkspace": "${aws:PrincipalTag/HcpTerraformWorkspace}" }
}
},
{
"Sid": "LockHcpTerraformWorkspaceTag",
"Effect": "Deny",
"Action": ["iam:TagRole", "iam:UntagRole"],
"Resource": "arn:aws:iam::396287094661:role/*",
"Condition": {
"ArnNotLike": { "aws:PrincipalArn": ["arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", "arn:aws:iam::396287094661:role/cdk-hnb659fds-*"] },
"ForAnyValue:StringEquals": { "aws:TagKeys": "HcpTerraformWorkspace" }
}
}
]
}

View file

@ -17,14 +17,26 @@ export interface TerraformSubstrateStackProps extends cdk.StackProps {
* remove the orphaned provider or redeploy with this false.
*/
createOidcProvider?: boolean;
/**
* Enable the two SHOC backend tf-poc HCP roles. Defaults false so the
* external-dev base-stack create is role-free.
*/
enableShocBackendPocRoles?: boolean;
/**
* Enable the four existing SHOC backend dev/staging HCP roles. Defaults
* false because these names must enter the stack through CloudFormation
* resource import, never a normal create/update.
*/
enableShocBackendLiveRoles?: boolean;
}
/**
* Per-account HCP Terraform deploy substrate: the shared account-level
* resources every Terraform workspace pipeline needs -
* - app.terraform.io OIDC identity provider (conditional, see props), and
* - `seahaven-hcptf-iam-management`, the shared boundary-gated IAM
* guardrail policy every per-workspace APPLY role attaches.
* 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.
*
* Deliberately NOT here: per-workspace hcptf-<stack> / hcptf-<stack>-plan
* roles. Those are appended to the template at each stack's migration time
@ -58,6 +70,10 @@ export class TerraformSubstrateStack extends cdk.Stack {
),
parameters: {
CreateOIDCProvider: props?.createOidcProvider === false ? "false" : "true",
EnableShocBackendPocRoles:
props?.enableShocBackendPocRoles === true ? "true" : "false",
EnableShocBackendLiveRoles:
props?.enableShocBackendLiveRoles === true ? "true" : "false",
},
});

File diff suppressed because it is too large Load diff