From 98c11e09add37b4493768629b1611f27e1094ac9 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:36:55 -0400 Subject: [PATCH] feat(ci): add the seahaven-ci baseline and runner exec roles (PLAT-253) (#180) * feat(ci): add the seahaven-ci baseline and runner exec roles * fix(ci): allow collection reads on the runner apply role --- .github/workflows/deploy.yaml | 9 + README.md | 3 + bin/app.ts | 24 ++ lib/actions-runner-hcptf-stack.ts | 472 ++++++++++++++++++++++++++++++ lib/view-access-stack.ts | 7 + 5 files changed, 515 insertions(+) create mode 100644 lib/actions-runner-hcptf-stack.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c0ad364..add1c8e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -65,3 +65,12 @@ jobs: stack-name: "seahaven-prod-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }} + + deploy-ci: + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7 + with: + node-version: "24" + stacks: "ci-baseline seahaven-hcptf-ci" + stack-name: "seahaven-ci-baseline" + secrets: + deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_CI }} diff --git a/README.md b/README.md index f442249..c5ae078 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ the TypeScript source — no separate compile step needed for `cdk synth` / | `app-web-acl-prod` | `seahaven-app-web-acl` | 011934824531 | us-east-1 | `lib/app-web-acl-stack.ts` | | `seahaven-hcptf` | `seahaven-hcptf` | 011934824531 | us-east-1 | `lib/seahaven-hcptf-stack.ts` | | `seahaven-hcptf-dev` | `seahaven-hcptf` | 710827005802 | us-east-1 | `lib/seahaven-hcptf-stack.ts` | +| `ci-baseline` | `seahaven-ci-baseline` | 188424654861 | us-east-1 | `lib/member-baseline-stack.ts` (orgManagedDetection) | +| `seahaven-hcptf-ci` | `seahaven-hcptf` | 188424654861 | us-east-1 | `lib/actions-runner-hcptf-stack.ts` | Member-account stacks deploy with per-account credentials — the CD workflow runs one job per account, each assuming that account's OIDC deploy role. Local @@ -92,6 +94,7 @@ deploys/diffs assume `OrganizationAccountAccessRole` in the target account. | 001520130573 (security) | `githubdeploy-seahaven-org-baseline` | `AWS_DEPLOY_ROLE_ARN_SECURITY` | | 710827005802 (dev) | `githubdeploy-seahaven-org-baseline` | `AWS_DEPLOY_ROLE_ARN_DEV` | | 011934824531 (prod) | `githubdeploy-seahaven-org-baseline` | `AWS_DEPLOY_ROLE_ARN_PROD` | +| 188424654861 (ci) | `githubdeploy-seahaven-org-baseline` | `AWS_DEPLOY_ROLE_ARN_CI` | Shared constructs (`DetectiveControls`, `FlowLogs`, `GovernanceToggles`) are prefix-parameterized — construct ids and physical names must stay diff --git a/bin/app.ts b/bin/app.ts index cc9a64d..2f80091 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -16,12 +16,14 @@ import { OrgGovernanceStack } from "../lib/org-governance-stack"; import { PlatformAccessStack } from "../lib/platform-access-stack"; import { EngineeringAccessStack } from "../lib/engineering-access-stack"; import { ViewAccessStack } from "../lib/view-access-stack"; +import { ActionsRunnerHcptfStack } from "../lib/actions-runner-hcptf-stack"; const ACCOUNT = "328440206208"; const EXTERNAL_DEV_ACCOUNT = "396287094661"; const SECURITY_ACCOUNT = "001520130573"; const DEV_ACCOUNT = "710827005802"; const PROD_ACCOUNT = "011934824531"; +const CI_ACCOUNT = "188424654861"; // Index-derived logical IDs — append only, never reorder, never close a hole. // Slots 0-2 are retired VPCs (FlowLog0, FlowLog1, FlowLog2). Clearing them @@ -104,6 +106,7 @@ new ViewAccessStack(app, "view-access", { externalDevAccountId: EXTERNAL_DEV_ACCOUNT, devAccountId: DEV_ACCOUNT, prodAccountId: PROD_ACCOUNT, + ciAccountId: CI_ACCOUNT, }); new MemberBaselineStack(app, "external-dev-baseline", { @@ -201,6 +204,27 @@ new MemberBaselineStack(app, "prod-baseline", { orgManagedDetection: true, }); +// ── Member-account baseline: seahaven-ci (PLAT-253) ───────────────────────── +// Runner cluster account. Created at the org root, then moved into nonprod +// after this baseline exists. No SAM deploy substrate. Default VPC is deleted. +// Flow logs for the cluster VPC are owned by the actions-runner workspace. +new MemberBaselineStack(app, "ci-baseline", { + stackName: "seahaven-ci-baseline", + env: { account: CI_ACCOUNT, region: "us-east-1" }, + namePrefix: "seahaven-ci", + monthlyBudgetUsd: 300, + budgetAlertEmail: "aws@seahaven.com", + ownerEmail: "adam@seahaven.com", + flowLogVpcIds: [], + managedByTag: "seahaven-org-baseline", + orgManagedDetection: true, +}); + +new ActionsRunnerHcptfStack(app, "seahaven-hcptf-ci", { + stackName: "seahaven-hcptf", + env: { account: CI_ACCOUNT, region: "us-east-1" }, +}); + // ── Per-account GitHub Actions deploy substrate ────────────────────────────── // The shared account-level deploy plumbing for SAM pipelines: permissions // boundary + github-cfn-execution-role (+ optional OIDC provider). mgmt's diff --git a/lib/actions-runner-hcptf-stack.ts b/lib/actions-runner-hcptf-stack.ts new file mode 100644 index 0000000..7e82760 --- /dev/null +++ b/lib/actions-runner-hcptf-stack.ts @@ -0,0 +1,472 @@ +import * as cdk from "aws-cdk-lib"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { Construct } from "constructs"; +import { HcptfPolicyAspect } from "./hcptf-policy-aspect"; + +const ACCOUNT = "188424654861"; +const GITHUB_THUMBPRINT = "ab9d0263244dd0326eb67015705a667e79cfe998"; +const HCP_THUMBPRINT = "9e99a48a9960b14926bb7f3b02e22da2b0ab7280"; + +/** + * seahaven-ci exec roles for the actions-runner HCP workspace (PLAT-253). + * + * This account runs only the runner cluster. The stack also holds the GitHub + * and HCP OIDC providers and the org-baseline CDK deploy role, because both + * have to exist before the first CD run. Roles are a plain create. + */ +export class ActionsRunnerHcptfStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + cdk.Tags.of(this).add("Project", "actions-runner"); + cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); + cdk.Tags.of(this).add("ManagedBy", "cdk"); + + const githubOidc = new iam.CfnOIDCProvider(this, "GithubOidc", { + url: "https://token.actions.githubusercontent.com", + clientIdList: ["sts.amazonaws.com"], + thumbprintList: [GITHUB_THUMBPRINT], + tags: roleTags("actions-runner"), + }); + retain(githubOidc); + + const hcpOidc = new iam.CfnOIDCProvider(this, "HcpOidc", { + url: "https://app.terraform.io", + clientIdList: ["aws.workload.identity"], + thumbprintList: [HCP_THUMBPRINT], + tags: roleTags("actions-runner"), + }); + retain(hcpOidc); + + const hcpArn = hcpOidc.attrArn; + const services = managedPolicy( + this, + "ServicesPolicy", + "actions-runner-hcptf-services", + servicesPolicy(), + ); + const iamPolicy = managedPolicy( + this, + "IamPolicy", + "actions-runner-hcptf-iam", + iamPolicyDocument(), + ); + const planRefresh = managedPolicy( + this, + "PlanPolicy", + "actions-runner-hcptf-plan", + planPolicy(hcpArn, githubOidc.attrArn), + ); + + const apply = new iam.CfnRole(this, "ApplyRole", { + roleName: "hcptf-actions-runner", + maxSessionDuration: 3600, + assumeRolePolicyDocument: trust(hcpArn, "apply"), + managedPolicyArns: [services.ref, iamPolicy.ref], + tags: roleTags("actions-runner"), + }); + retain(apply); + + const plan = new iam.CfnRole(this, "PlanRole", { + roleName: "hcptf-actions-runner-plan", + maxSessionDuration: 3600, + assumeRolePolicyDocument: trust(hcpArn, "plan"), + managedPolicyArns: [ + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + planRefresh.ref, + ], + tags: roleTags("actions-runner"), + }); + retain(plan); + + const deployPolicy = managedPolicy( + this, + "OrgBaselineDeployPolicy", + "actions-runner-org-baseline-deploy", + githubDeployPolicy(), + ); + const deploy = new iam.CfnRole(this, "OrgBaselineDeployRole", { + roleName: "githubdeploy-seahaven-org-baseline", + description: + "GitHub Actions OIDC deploy role for seahaven-org-baseline (ci-baseline stack)", + maxSessionDuration: 3600, + assumeRolePolicyDocument: githubTrust(githubOidc.attrArn), + managedPolicyArns: [deployPolicy.ref], + tags: roleTags("actions-runner"), + }); + retain(deploy); + + new cdk.CfnOutput(this, "ApplyRoleArn", { value: apply.attrArn }); + new cdk.CfnOutput(this, "PlanRoleArn", { value: plan.attrArn }); + new cdk.CfnOutput(this, "OrgBaselineDeployRoleArn", { + value: deploy.attrArn, + }); + + cdk.Aspects.of(this).add(new HcptfPolicyAspect()); + } +} + +function retain(resource: cdk.CfnResource): void { + resource.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + resource.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; +} + +function roleTags(project: string): cdk.CfnTag[] { + return [ + { key: "Project", value: project }, + { key: "Owner", value: "adam@seahavenind.com" }, + { key: "ManagedBy", value: "cdk" }, + ]; +} + +function managedPolicy( + scope: Construct, + id: string, + name: string, + policyDocument: object, +): iam.CfnManagedPolicy { + const policy = new iam.CfnManagedPolicy(scope, id, { + managedPolicyName: name, + path: "/tf-managed/", + policyDocument, + }); + retain(policy); + return policy; +} + +function trust(providerArn: string, phase: "apply" | "plan"): iam.PolicyDocument { + return iam.PolicyDocument.fromJson({ + Version: "2012-10-17", + Statement: [ + { + Sid: phase === "apply" ? "HcpApply" : "HcpPlan", + Effect: "Allow", + Action: "sts:AssumeRoleWithWebIdentity", + Principal: { Federated: providerArn }, + Condition: { + StringEquals: { + "app.terraform.io:aud": "aws.workload.identity", + "app.terraform.io:sub": `organization:seahaven:project:seahaven-ci:workspace:actions-runner:run_phase:${phase}`, + }, + }, + }, + ], + }); +} + +function githubTrust(providerArn: string): iam.PolicyDocument { + return iam.PolicyDocument.fromJson({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "sts:AssumeRoleWithWebIdentity", + Principal: { Federated: providerArn }, + Condition: { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + "token.actions.githubusercontent.com:sub": + "repo:Sea-Haven-Industries/seahaven-org-baseline:ref:refs/heads/main", + }, + }, + }, + ], + }); +} + +function githubDeployPolicy(): object { + return { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "sts:AssumeRole", + Resource: `arn:aws:iam::${ACCOUNT}:role/cdk-hnb659fds-*`, + }, + { + Effect: "Allow", + Action: "cloudformation:DescribeStacks", + Resource: [ + `arn:aws:cloudformation:us-east-1:${ACCOUNT}:stack/seahaven-ci-baseline/*`, + `arn:aws:cloudformation:us-east-1:${ACCOUNT}:stack/seahaven-hcptf/*`, + ], + }, + ], + }; +} + +function servicesPolicy(): object { + const prefix = "actions-runner"; + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "Ec2ForCluster", + Effect: "Allow", + Action: "ec2:*", + Resource: "*", + }, + { + Sid: "EksCluster", + Effect: "Allow", + Action: "eks:*", + Resource: "*", + }, + { + Sid: "Logs", + Effect: "Allow", + Action: [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DeleteLogGroup", + "logs:DeleteRetentionPolicy", + "logs:ListTagsForResource", + "logs:PutRetentionPolicy", + "logs:TagResource", + "logs:UntagResource", + ], + Resource: `arn:aws:logs:us-east-1:${ACCOUNT}:log-group:*`, + }, + { + Sid: "LogGroupList", + Effect: "Allow", + Action: "logs:DescribeLogGroups", + Resource: "*", + }, + { + Sid: "KmsForCluster", + Effect: "Allow", + Action: [ + "kms:CreateAlias", + "kms:CreateGrant", + "kms:CreateKey", + "kms:DeleteAlias", + "kms:DescribeKey", + "kms:EnableKeyRotation", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListAliases", + "kms:ListResourceTags", + "kms:PutKeyPolicy", + "kms:ScheduleKeyDeletion", + "kms:TagResource", + "kms:UntagResource", + "kms:UpdateAlias", + ], + Resource: "*", + }, + { + Sid: "EcrPublicAuth", + Effect: "Allow", + Action: "ecr-public:GetAuthorizationToken", + Resource: "*", + }, + { + Sid: "StsForPublicEcr", + Effect: "Allow", + Action: "sts:GetServiceBearerToken", + Resource: "*", + }, + { + Sid: "EcrRepository", + Effect: "Allow", + Action: [ + "ecr:CreateRepository", + "ecr:DeleteRepository", + "ecr:DescribeRepositories", + "ecr:ListTagsForResource", + "ecr:PutImageTagMutability", + "ecr:PutLifecyclePolicy", + "ecr:DeleteLifecyclePolicy", + "ecr:GetLifecyclePolicy", + "ecr:PutImageScanningConfiguration", + "ecr:TagResource", + "ecr:UntagResource", + "ecr:SetRepositoryPolicy", + "ecr:GetRepositoryPolicy", + "ecr:DeleteRepositoryPolicy", + ], + Resource: `arn:aws:ecr:us-east-1:${ACCOUNT}:repository/${prefix}`, + }, + { + Sid: "SsmContract", + Effect: "Allow", + Action: [ + "ssm:AddTagsToResource", + "ssm:DeleteParameter", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:ListTagsForResource", + "ssm:PutParameter", + "ssm:RemoveTagsFromResource", + ], + Resource: `arn:aws:ssm:us-east-1:${ACCOUNT}:parameter/${prefix}/*`, + }, + { + Sid: "SsmList", + Effect: "Allow", + Action: "ssm:DescribeParameters", + Resource: "*", + }, + ], + }; +} + +function iamPolicyDocument(): object { + const roleArn = `arn:aws:iam::${ACCOUNT}:role/actions-runner*`; + const managedRole = `arn:aws:iam::${ACCOUNT}:role/tf-managed/*`; + const policyArn = `arn:aws:iam::${ACCOUNT}:policy/actions-runner*`; + const managedPolicyArn = `arn:aws:iam::${ACCOUNT}:policy/tf-managed/*`; + const profileArn = `arn:aws:iam::${ACCOUNT}:instance-profile/actions-runner*`; + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "RunnerRoles", + Effect: "Allow", + Action: [ + "iam:AttachRolePolicy", + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListRolePolicies", + "iam:ListRoleTags", + "iam:PassRole", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ], + Resource: [roleArn, managedRole], + }, + { + Sid: "RunnerPolicies", + Effect: "Allow", + Action: [ + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyTags", + "iam:ListPolicyVersions", + "iam:TagPolicy", + "iam:UntagPolicy", + ], + Resource: [policyArn, managedPolicyArn], + }, + { + Sid: "InstanceProfiles", + Effect: "Allow", + Action: [ + "iam:AddRoleToInstanceProfile", + "iam:CreateInstanceProfile", + "iam:DeleteInstanceProfile", + "iam:GetInstanceProfile", + "iam:RemoveRoleFromInstanceProfile", + "iam:TagInstanceProfile", + ], + Resource: profileArn, + }, + { + Sid: "OidcProviders", + Effect: "Allow", + Action: [ + "iam:AddClientIDToOpenIDConnectProvider", + "iam:CreateOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:ListOpenIDConnectProviderTags", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "iam:UpdateOpenIDConnectProviderThumbprint", + ], + Resource: `arn:aws:iam::${ACCOUNT}:oidc-provider/*`, + }, + { + Sid: "ServiceLinkedRoles", + Effect: "Allow", + Action: "iam:CreateServiceLinkedRole", + Resource: `arn:aws:iam::${ACCOUNT}:role/aws-service-role/*`, + Condition: { + StringEquals: { + "iam:AWSServiceName": [ + "eks.amazonaws.com", + "eks-nodegroup.amazonaws.com", + "eks-fargate-pods.amazonaws.com", + ], + }, + }, + }, + { + Sid: "PassAwsServiceRoles", + Effect: "Allow", + Action: "iam:PassRole", + Resource: `arn:aws:iam::${ACCOUNT}:role/aws-service-role/eks*`, + }, + ], + }; +} + +function planPolicy(hcpArn: string, githubArn: string): object { + return { + Version: "2012-10-17", + Statement: [ + { + Sid: "NamedIamReads", + Effect: "Allow", + Action: [ + "iam:GetOpenIDConnectProvider", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + ], + Resource: [ + `arn:aws:iam::${ACCOUNT}:role/actions-runner*`, + `arn:aws:iam::${ACCOUNT}:role/tf-managed/*`, + `arn:aws:iam::${ACCOUNT}:role/hcptf-actions-runner`, + `arn:aws:iam::${ACCOUNT}:role/hcptf-actions-runner-plan`, + `arn:aws:iam::${ACCOUNT}:role/githubdeploy-seahaven-org-baseline`, + `arn:aws:iam::${ACCOUNT}:policy/actions-runner*`, + `arn:aws:iam::${ACCOUNT}:policy/tf-managed/*`, + hcpArn, + githubArn, + ], + }, + { + Sid: "EksAccessReads", + Effect: "Allow", + Action: [ + "eks:DescribeAccessEntry", + "eks:DescribeAddon", + "eks:DescribeCluster", + "eks:DescribeFargateProfile", + "eks:DescribeNodegroup", + "eks:DescribeUpdate", + "eks:ListAccessEntries", + "eks:ListAssociatedAccessPolicies", + ], + Resource: `arn:aws:eks:us-east-1:${ACCOUNT}:cluster/actions-runner`, + }, + { + Sid: "SsmReads", + Effect: "Allow", + Action: ["ssm:GetParameter", "ssm:GetParameters", "ssm:ListTagsForResource"], + Resource: `arn:aws:ssm:us-east-1:${ACCOUNT}:parameter/actions-runner/*`, + }, + ], + }; +} diff --git a/lib/view-access-stack.ts b/lib/view-access-stack.ts index ca68ff9..4464575 100644 --- a/lib/view-access-stack.ts +++ b/lib/view-access-stack.ts @@ -14,6 +14,7 @@ export interface ViewAccessStackProps extends cdk.StackProps { externalDevAccountId: string; devAccountId: string; prodAccountId: string; + ciAccountId: string; } /** @@ -76,6 +77,12 @@ export class ViewAccessStack extends cdk.Stack { group, props.prodAccountId, ); + this.assignment( + "ViewCiAssignment", + permissionSet, + group, + props.ciAccountId, + ); } private assignment(