open-swe/infra/lib/constructs/github-deploy-roles.ts
Adam Moussa e9499e49b8
fix: isolate dev CDK deploys on their own bootstrap qualifier (B-1/OSWE-IAC-01) (#55)
* 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)
2026-06-29 12:39:23 -04:00

173 lines
7.6 KiB
TypeScript

import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import {
ACCOUNT,
EnvName,
GITHUB_OIDC_PROVIDER_ARN,
REGION,
bootstrapQualifier,
oidcSubject,
} from "../config";
/**
* Per-ENV GitHub Actions OIDC deploy roles. Created ONCE per env in the
* dedicated `open-swe-iam` stack. Two roles per env, per the locked architecture's
* "dual OIDC roles":
*
* - githubdeploy-open-swe-infra-<env> → CFN/IAM (CDK) deploys of that env's stack
* - githubdeploy-open-swe-app-<env> → app deploys (env-tag-scoped SSM + S3 read)
*
* T5 OSWE-IAC-01/02 fix: roles are split per env and the trust subject is
* env-scoped (dev = dev branch ref; prod = the GitHub `prod` Environment subject,
* so the manual-approval gate is IAM-enforced). A dev-branch token therefore
* cannot SendCommand to the prod box nor assume a prod deploy role.
*
* Reviewed at T4 (GPT-4.1 IAM cross-review) + T5 (/sh-security-review) and
* deployed FIRST (BLOCK#3 "OIDC-role-first" ordering) before any other infra or
* secrets CI step.
*/
export class GithubDeployRoles extends Construct {
public readonly infraRole: iam.Role;
public readonly appRole: iam.Role;
constructor(scope: Construct, id: string, envName: EnvName) {
super(scope, id);
// The provider already exists account-wide — reference, never re-create.
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
this,
"GithubOidcProvider",
GITHUB_OIDC_PROVIDER_ARN,
);
// T4 BLOCK#2 + T5 IAC-01/02: exact env-scoped subject via StringEquals (no
// StringLike, no `*`). prod = environment:prod (manual-approval gate),
// dev = the dev branch ref.
const trust = new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": oidcSubject(envName),
},
});
// ---- githubdeploy-open-swe-infra-<env> --------------------------------
this.infraRole = new iam.Role(this, "InfraDeployRole", {
roleName: `githubdeploy-open-swe-infra-${envName}`,
assumedBy: trust,
description: `GitHub OIDC role for CDK deploys of the open-swe-${envName} infra stack (assumes CDK bootstrap roles).`,
});
// Org-standard CDK deploy pattern (mirrors githubdeploy-seahaven-account-
// baseline / -forgejo / -apm-wo-analysis): the deploy role only needs to
// assume the CDK bootstrap roles. The actual CloudFormation + IAM + resource
// permissions are exercised by the bootstrap `cfn-exec-role`, whose scope is
// owned by the CDKToolkit stack — NOT granted directly here.
//
// 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-${bootstrapQualifier(envName)}-*`],
}),
);
// ---- githubdeploy-open-swe-app-<env> ----------------------------------
this.appRole = new iam.Role(this, "AppDeployRole", {
roleName: `githubdeploy-open-swe-app-${envName}`,
assumedBy: trust,
description: `GitHub OIDC role for open-swe-${envName} app deploys: env-tag-scoped ssm:SendCommand + read of the ${envName} S3 artifact bucket.`,
});
// T5 OSWE-IAC-01 fix: SendCommand only to instances tagged project=open-swe
// AND env=<this env> (a SINGLE value, not {dev,prod}). The dev app role can
// never command the prod box and vice versa — env isolation in IAM.
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "SsmSendCommandTagScoped",
actions: ["ssm:SendCommand"],
resources: [`arn:aws:ec2:${REGION}:${ACCOUNT}:instance/*`],
conditions: {
StringEquals: {
"ssm:resourceTag/project": "open-swe",
"ssm:resourceTag/env": envName,
},
},
}),
);
// SendCommand also has to reference the command document. Scope to this env's
// open-swe deploy document ONLY.
// T4 BLOCK#3 (CLOSED at T19): GPT-4.1 flagged AWS-RunShellScript as an
// arbitrary-shell escalation path. The dedicated `open-swe-${envName}-deploy`
// SSM document (app-service.ts) now runs the fixed, parameter-less command
// `bash /opt/open-swe/bin/deploy.sh`, so AWS-RunShellScript is dropped here:
// this role can run ONLY that one document, and only on its own env's box
// (tag-scoped by the SsmSendCommandTagScoped statement above).
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "SsmSendCommandDocuments",
actions: ["ssm:SendCommand"],
resources: [`arn:aws:ssm:${REGION}:${ACCOUNT}:document/open-swe-${envName}-deploy`],
}),
);
// Poll command results. These read actions do not support resource-level
// scoping, so `*` is required by the API (T4 FIX: API limitation, documented).
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "SsmReadCommandStatus",
actions: [
"ssm:GetCommandInvocation",
"ssm:ListCommands",
"ssm:ListCommandInvocations",
],
resources: ["*"],
}),
);
// Read+WRITE access to THIS env's artifact bucket only (T19): the
// build-artifacts workflow uploads app.tar.gz / spa.tar.gz under releases/*,
// then fires the deploy document so the box pulls them via its instance role.
// Object actions are scoped to releases/* (the only prefix CI writes), and to
// THIS env's bucket — a dev token can never write the prod bucket. No
// bucket-level mutation (no PutBucket*/Delete bucket) — that stays with CDK.
// GetObject + PutObject (S3-to-S3 copy = Get source + Put dest) is all the
// publish/rollback path uses; s3:DeleteObject is deliberately NOT granted so a
// CI token cannot erase an immutable release or the releases/last-good rollback
// fallback (lifecycle expiry handles old-version cleanup, not CI).
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "ReadWriteArtifactObjects",
actions: ["s3:GetObject", "s3:PutObject"],
resources: [`arn:aws:s3:::open-swe-${envName}-assets/releases/*`],
}),
);
// ListBucket is constrained to the releases/ prefix (F-1/IAC-04): the
// publish/rollback scripts only ever list under releases/, so a leaked CI
// token cannot enumerate anything else in the bucket. GetBucketLocation
// carries no s3:prefix, so it stays a separate, unconditioned statement.
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "ListArtifactBucket",
actions: ["s3:ListBucket"],
resources: [`arn:aws:s3:::open-swe-${envName}-assets`],
conditions: { StringLike: { "s3:prefix": ["releases/*"] } },
}),
);
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "GetArtifactBucketLocation",
actions: ["s3:GetBucketLocation"],
resources: [`arn:aws:s3:::open-swe-${envName}-assets`],
}),
);
}
}