shoc-backend/infra/cdk/deploy-staging-stack.ts
Alexandre Brandizzi 4aef5b463b fix: grant staging deploy role the Support-confirmed Elastic Beanstalk S3 set
s3:PutObject on shoc-backend/* covered only the upload the deploy action
performs itself. AWS Support case 178526484500047 established that
UpdateEnvironment then reads, writes, versions, ACL-checks and removes
objects as the calling identity, across both the account bucket and
AWS-owned Elastic Beanstalk service buckets whose names cannot be
enumerated in advance. That is the failure the dev lane already hit, and
staging calls the same deploy action and the same rollback
update-environment, so the first OIDC deploy and every rollback would
have stopped there.

Brings the staging role to parity with deploy-dev-stack.ts.
elasticbeanstalk:UpdateEnvironment stays pinned to the staging
environment ARN, so the role still cannot update or terminate
shoc-backend-dev.
2026-08-31 10:57:04 -03:00

200 lines
7.6 KiB
TypeScript

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