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 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`], }), ); } }