import * as iam from "aws-cdk-lib/aws-iam"; import { Construct } from "constructs"; import { ACCOUNT, EnvName, GITHUB_OIDC_PROVIDER_ARN, REGION, 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- → CFN/IAM (CDK) deploys of that env's stack * - githubdeploy-open-swe-app- → 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- -------------------------------- 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. // // 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). this.infraRole.addToPolicy( new iam.PolicyStatement({ sid: "AssumeCdkBootstrapRoles", actions: ["sts:AssumeRole"], resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-hnb659fds-*`], }), ); // ---- githubdeploy-open-swe-app- ---------------------------------- 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= (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. // T4 BLOCK#3: GPT-4.1 flagged AWS-RunShellScript as an arbitrary-shell escalation // path. TIMEBOXED (accepted until T19): the current SSM deploy runs deploy.sh via // AWS-RunShellScript; it is already tag-scoped to env= (statement above). // TODO(T19): drop AWS-RunShellScript once `open-swe--deploy` is the only path. this.appRole.addToPolicy( new iam.PolicyStatement({ sid: "SsmSendCommandDocuments", actions: ["ssm:SendCommand"], resources: [ `arn:aws:ssm:${REGION}:${ACCOUNT}:document/open-swe-${envName}-deploy`, `arn:aws:ssm:${REGION}::document/AWS-RunShellScript`, ], }), ); // 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-only access to THIS env's artifact bucket only (CI uploads; the box // pulls via its instance role — the app deploy role only reads to verify). this.appRole.addToPolicy( new iam.PolicyStatement({ sid: "ReadArtifactBucket", actions: ["s3:GetObject"], resources: [`arn:aws:s3:::open-swe-${envName}-assets/*`], }), ); this.appRole.addToPolicy( new iam.PolicyStatement({ sid: "ListArtifactBucket", actions: ["s3:ListBucket", "s3:GetBucketLocation"], resources: [`arn:aws:s3:::open-swe-${envName}-assets`], }), ); } }