import * as cdk from 'aws-cdk-lib'; import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; const ACCOUNT_ID = '396287094661'; const REGION = 'us-east-1'; const APPLICATION_NAME = 'shoc-backend'; const ENVIRONMENT_NAME = 'shoc-backend-staging'; const ENVIRONMENT_ID = 'e-6c9m4vb62z'; const ENVIRONMENT_STACK_NAME = `awseb-${ENVIRONMENT_ID}-stack`; const REPO = 'Sea-Haven-Industries/shoc-backend'; const RUNTIME_ROLE_NAME = 'shoc-backend-staging'; const EXTERNAL_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB'; const EXTERNAL_WEBHOOK_KEY_ARN = 'arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18'; export class DeployStagingStack extends cdk.Stack { constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { super(scope, id, props); const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; const deployRole = new iam.Role(this, 'GithubDeployRole', { roleName: 'githubdeploy-shoc-backend-staging', description: 'Least-privilege GitHub OIDC deploy role for shoc-backend staging. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk.', assumedBy: new iam.FederatedPrincipal( oidcProviderArn, { StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', 'token.actions.githubusercontent.com:sub': `repo:${REPO}:environment:staging`, }, }, 'sts:AssumeRoleWithWebIdentity', ), }); deployRole.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); const cfnRole = deployRole.node.defaultChild as iam.CfnRole; cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'autoscaling:Describe*', 'ec2:Describe*', 'elasticbeanstalk:DescribeEnvironments', 'elasticbeanstalk:DescribeApplicationVersions', 'elasticbeanstalk:DescribeEvents', 'elasticloadbalancing:Describe*', ], resources: ['*'], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['elasticbeanstalk:CreateApplicationVersion'], resources: [ applicationArn, `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:applicationversion/${APPLICATION_NAME}/*`, ], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['elasticbeanstalk:UpdateEnvironment'], resources: [environmentArn], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'cloudformation:DescribeStackEvents', 'cloudformation:DescribeStackResource', 'cloudformation:GetTemplate', 'cloudformation:DescribeStackResources', 'cloudformation:DescribeStacks', 'cloudformation:ListStackResources', 'cloudformation:CancelUpdateStack', 'cloudformation:UpdateStack', ], resources: [ `arn:aws:cloudformation:${REGION}:${ACCOUNT_ID}:stack/${ENVIRONMENT_STACK_NAME}/*`, ], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'autoscaling:PutNotificationConfiguration', 'autoscaling:ResumeProcesses', 'autoscaling:SuspendProcesses', ], resources: [ `arn:aws:autoscaling:${REGION}:${ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/${ENVIRONMENT_STACK_NAME}-*`, ], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['s3:Delete*', 's3:Get*', 's3:Put*'], // Matches deploy-dev-stack.ts. The narrower s3:PutObject on // shoc-backend/* covers only the upload the deploy action performs // itself; AWS Support case 178526484500047 confirmed that // UpdateEnvironment then reads, writes, versions, ACL-checks, and // removes objects as the calling identity, in both the account bucket // and AWS-owned Elastic Beanstalk service buckets whose names are not // enumerable in advance. That is the failure the dev lane already hit; // staging calls the same deploy action and the same rollback // update-environment, so it needs the same set or the first OIDC // deploy and every rollback stop there. // // NOTE: dev and staging are two environments of ONE Elastic Beanstalk // application (APPLICATION_NAME is shared), so application versions for // both live under the same shoc-backend/ prefix in the same account // bucket. This grant therefore reaches dev's application-version // objects. Environment authority stays separate -- // elasticbeanstalk:UpdateEnvironment below is pinned to the staging // environment ARN, so this role still cannot update or terminate // shoc-backend-dev. resources: ['arn:aws:s3:::elasticbeanstalk-*/*'], }), ); deployRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 's3:GetBucket*', 's3:ListBucket', 's3:PutBucketOwnershipControls', 's3:PutBucketPolicy', 's3:PutBucketPublicAccessBlock', ], // AWS Support's bucket-level UpdateEnvironment set, excluding // CreateBucket because the workflow deploys only to an existing // application/environment and disables bucket creation. resources: ['arn:aws:s3:::elasticbeanstalk-*'], }), ); new cdk.CfnOutput(this, 'GithubDeployRoleArn', { value: deployRole.roleArn, description: 'ARN of the GitHub OIDC deploy role for shoc-backend staging.', exportName: 'shoc-backend-deploy-staging-role-arn', }); // Identity-side grants on the existing Elastic Beanstalk runtime role so // the staging application can read the cross-account webhook secret and // decrypt it through Secrets Manager only. The secret and KMS key live in // the external account 011934824531; their resource policies are owned by // that account and are NOT modified or solved by this stack. const runtimeRole = iam.Role.fromRoleName( this, 'RuntimeRole', RUNTIME_ROLE_NAME, ); new iam.Policy(this, 'RuntimeRoleWebhookSecretPolicy', { policyName: 'shoc-backend-staging-webhook-secret-access', roles: [runtimeRole], statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret', ], resources: [EXTERNAL_WEBHOOK_SECRET_ARN], }), new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['kms:Decrypt'], resources: [EXTERNAL_WEBHOOK_KEY_ARN], conditions: { StringEquals: { 'kms:ViaService': 'secretsmanager.us-east-1.amazonaws.com', }, }, }), ], }); } }