From e9499e49b854ea49dee0be89e36b2a0cdc5b3249 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:39:23 -0400 Subject: [PATCH] fix: isolate dev CDK deploys on their own bootstrap qualifier (B-1/OSWE-IAC-01) (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: isolate dev CDK deploys on their own bootstrap qualifier (B-1) Dev synthesizes against the oswedev qualifier and the dev infra deploy role is scoped to cdk-oswedev-* — it can no longer assume the default hnb659fds bootstrap roles whose admin cfn-exec-role deploys prod, closing the cross-env escalation (OSWE-IAC-01). Prod stays on the default qualifier. * test: assert per-env bootstrap qualifier isolation + document (B-1) --- infra/README.md | 26 ++++++++++ infra/bin/app.ts | 10 +++- infra/lib/config.ts | 20 ++++++-- infra/lib/constructs/github-deploy-roles.ts | 20 ++++---- infra/test/bootstrap-qualifier.test.ts | 55 +++++++++++++++++++++ 5 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 infra/test/bootstrap-qualifier.test.ts diff --git a/infra/README.md b/infra/README.md index 7eb87ddd..b5c866a8 100644 --- a/infra/README.md +++ b/infra/README.md @@ -66,6 +66,32 @@ never defaults to PascalCase; resource names follow `open-swe--*`. The GitHub OIDC provider already exists account-wide (created for seahaven-site); it is referenced by ARN, never re-created. +## CDK bootstrap qualifiers — per-env deploy isolation (B-1 / OSWE-IAC-01) + +Each env's infra deploy role may assume **only its own bootstrap qualifier's** +roles, so a dev-branch token can never assume the bootstrap roles whose admin +`cfn-exec-role` deploys prod (closing the cross-env escalation that bypassed +prod's Environment approval gate). Mapping lives in `config.ts:bootstrapQualifier`: + +| Env | Qualifier | Toolkit stack | Infra role assumes | +|---|---|---|---| +| dev | `oswedev` | `CDKToolkit-oswedev` | `cdk-oswedev-*` | +| prod | `hnb659fds` (default) | `CDKToolkit` | `cdk-hnb659fds-*` | + +The dev stack synthesizes with `DefaultStackSynthesizer({ qualifier: "oswedev" })` +(`bin/app.ts`); prod uses the default. Bootstrap a new env qualifier with: + +```bash +npx cdk bootstrap --qualifier --toolkit-stack-name CDKToolkit- \ + --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess \ + aws://328440206208/us-east-1 +``` + +**Deploy order matters** when changing an env's qualifier: bootstrap the new +qualifier and deploy the env stack onto it **before** re-scoping that env's infra +role in `open-swe-iam` — otherwise a pipeline deploy with the re-scoped role would +fail to assume the not-yet-targeted bootstrap roles. + ## Kebab-case naming Aspect `KebabNamingAspect` (applied app-wide in `bin/app.ts`) fails synth via diff --git a/infra/bin/app.ts b/infra/bin/app.ts index af44f303..720d566b 100644 --- a/infra/bin/app.ts +++ b/infra/bin/app.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import "source-map-support/register"; import * as cdk from "aws-cdk-lib"; -import { ACCOUNT, REGION } from "../lib/config"; +import { ACCOUNT, REGION, bootstrapQualifier } from "../lib/config"; import { OpenSweIamStack } from "../lib/open-swe-iam-stack"; import { OpenSweStack } from "../lib/open-swe-stack"; import { KebabNamingAspect } from "../lib/aspects/kebab-naming-aspect"; @@ -17,10 +17,18 @@ new OpenSweIamStack(app, "OpenSweIamStack", { // The two env stacks — explicit kebab-case stackName (never let CDK default to // PascalCase), env-parameterised so resources are `open-swe--*`. +// +// B-1 / OSWE-IAC-01: dev synthesizes against its OWN bootstrap qualifier +// (`oswedev`), so it deploys via the cdk-oswedev-* roles the dev infra role is +// scoped to — and NOT the default hnb659fds bootstrap roles that deploy prod. +// Prod stays on the default qualifier (no synthesizer override). new OpenSweStack(app, "OpenSweDevStack", { stackName: "open-swe-dev", env, envName: "dev", + synthesizer: new cdk.DefaultStackSynthesizer({ + qualifier: bootstrapQualifier("dev"), + }), }); new OpenSweStack(app, "OpenSweProdStack", { diff --git a/infra/lib/config.ts b/infra/lib/config.ts index 3267ced0..41b550bd 100644 --- a/infra/lib/config.ts +++ b/infra/lib/config.ts @@ -28,16 +28,28 @@ export const prefix = (env: EnvName): string => `open-swe-${env}`; * Each env gets its OWN infra + app role (githubdeploy-open-swe-{infra,app}-) * so a dev token cannot reach prod. Exact subject → StringEquals (no `*`). * - * Residual (documented): CDK's single account-wide `cfn-exec-role` means the dev - * INFRA role can still technically `cdk deploy open-swe-prod`; the workflow only - * ever targets its own env stack, and prod's environment-gated role is the - * approved path. Per-env bootstrap qualifiers would close this fully (future). + * Cross-env deploy isolation is enforced at the bootstrap layer too — see + * `bootstrapQualifier`: dev runs on its own qualifier so the dev infra role + * cannot assume the bootstrap roles that deploy prod. */ export const oidcSubject = (env: EnvName): string => env === "prod" ? `repo:${GITHUB_ORG}/${GITHUB_REPO}:environment:prod` : `repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/dev`; +/** + * Per-env CDK bootstrap qualifier (B-1 / OSWE-IAC-01 fix). Dev runs on its OWN + * qualifier `oswedev` (bootstrapped into the `CDKToolkit-oswedev` stack), so the + * dev infra deploy role only assumes `cdk-oswedev-*` and can NO LONGER assume the + * default `cdk-hnb659fds-*` set whose admin `cfn-exec-role` deploys prod. Prod + * stays on the default qualifier. This closes the cross-env escalation where a + * dev-branch token could `cdk deploy open-swe-prod` via the shared bootstrap + * roles, bypassing prod's Environment approval gate. + */ +export const DEFAULT_BOOTSTRAP_QUALIFIER = "hnb659fds"; +export const bootstrapQualifier = (env: EnvName): string => + env === "dev" ? "oswedev" : DEFAULT_BOOTSTRAP_QUALIFIER; + /** * The GitHub Actions OIDC provider already exists account-wide (created for * seahaven-site; see .github/oidc-deploy-roles.yaml `CreateOIDCProvider=false`). diff --git a/infra/lib/constructs/github-deploy-roles.ts b/infra/lib/constructs/github-deploy-roles.ts index 8d3bfeca..e383a568 100644 --- a/infra/lib/constructs/github-deploy-roles.ts +++ b/infra/lib/constructs/github-deploy-roles.ts @@ -5,6 +5,7 @@ import { EnvName, GITHUB_OIDC_PROVIDER_ARN, REGION, + bootstrapQualifier, oidcSubject, } from "../config"; @@ -62,20 +63,19 @@ export class GithubDeployRoles extends Construct { // permissions are exercised by the bootstrap `cfn-exec-role`, whose scope is // owned by the CDKToolkit stack — NOT granted directly here. // - // T4 BLOCK#1: GPT-4.1 flagged the `cdk-hnb659fds-*` wildcard and recommended - // enumerating the four exact ARNs. ACCEPTED EXCEPTION (Adam, 2026-06-26): kept - // as the verified org-wide convention (githubdeploy-seahaven-account-baseline - // uses the identical wildcard). Only `cdk bootstrap` creates roles with this - // prefix, so practical escalation risk is low. - // T5 residual (OSWE-IAC-02): the single account-wide cfn-exec-role means the - // dev infra role can technically deploy any stack; per-env trust gates WHO can - // assume, and the prod role requires the environment:prod approval. Per-env - // bootstrap qualifiers would close the residual fully (future hardening). + // B-1 / OSWE-IAC-01 fix: scope the assume to THIS env's bootstrap qualifier. + // Dev uses `oswedev` (its own CDKToolkit-oswedev bootstrap), prod uses the + // default `hnb659fds`. The dev infra role can therefore no longer assume the + // bootstrap roles whose admin cfn-exec-role deploys prod — closing the prior + // cross-env escalation (a dev-branch token could `cdk deploy open-swe-prod` + // via the shared account-wide bootstrap roles, bypassing prod's Environment + // approval gate). The qualifier wildcard still matches only the handful of + // roles `cdk bootstrap` creates for that qualifier. this.infraRole.addToPolicy( new iam.PolicyStatement({ sid: "AssumeCdkBootstrapRoles", actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-hnb659fds-*`], + resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-${bootstrapQualifier(envName)}-*`], }), ); diff --git a/infra/test/bootstrap-qualifier.test.ts b/infra/test/bootstrap-qualifier.test.ts new file mode 100644 index 00000000..51ff5852 --- /dev/null +++ b/infra/test/bootstrap-qualifier.test.ts @@ -0,0 +1,55 @@ +import * as cdk from "aws-cdk-lib"; +import { Match, Template } from "aws-cdk-lib/assertions"; +import { OpenSweIamStack } from "../lib/open-swe-iam-stack"; +import { bootstrapQualifier } from "../lib/config"; + +const ENV = { account: "328440206208", region: "us-east-1" }; + +// B-1 / OSWE-IAC-01: each env's infra deploy role may assume ONLY its own +// bootstrap qualifier's roles. Dev runs on `oswedev`, so a dev-branch token can +// no longer assume the default `hnb659fds` bootstrap roles whose admin +// cfn-exec-role deploys prod. Prod stays on the default qualifier. +describe("Per-env CDK bootstrap qualifier isolation (B-1/OSWE-IAC-01)", () => { + it("maps dev -> oswedev and prod -> hnb659fds", () => { + expect(bootstrapQualifier("dev")).toBe("oswedev"); + expect(bootstrapQualifier("prod")).toBe("hnb659fds"); + }); + + it("dev infra deploy role assumes only cdk-oswedev-* bootstrap roles", () => { + const app = new cdk.App(); + const stack = new OpenSweIamStack(app, "OpenSweIamStack", { + stackName: "open-swe-iam", + env: ENV, + }); + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Sid: "AssumeCdkBootstrapRoles", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::328440206208:role/cdk-oswedev-*", + }), + ]), + }), + }); + }); + + it("prod infra deploy role stays on the default cdk-hnb659fds-* bootstrap roles", () => { + const app = new cdk.App(); + const stack = new OpenSweIamStack(app, "OpenSweIamStack", { + stackName: "open-swe-iam", + env: ENV, + }); + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Sid: "AssumeCdkBootstrapRoles", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::328440206208:role/cdk-hnb659fds-*", + }), + ]), + }), + }); + }); +});