2026-08-27 20:13:37 -03:00
|
|
|
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,
|
2026-08-31 10:57:04 -03:00
|
|
|
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-*/*'],
|
2026-08-27 20:13:37 -03:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
deployRole.addToPolicy(
|
|
|
|
|
new iam.PolicyStatement({
|
|
|
|
|
effect: iam.Effect.ALLOW,
|
2026-08-31 10:57:04 -03:00
|
|
|
actions: [
|
|
|
|
|
's3:GetBucket*',
|
|
|
|
|
's3:ListBucket',
|
|
|
|
|
's3:PutBucketOwnershipControls',
|
|
|
|
|
's3:PutBucketPolicy',
|
|
|
|
|
's3:PutBucketPublicAccessBlock',
|
2026-08-27 20:13:37 -03:00
|
|
|
],
|
2026-08-31 10:57:04 -03:00
|
|
|
// 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-*'],
|
2026-08-27 20:13:37 -03:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|