fix(cdk): apply supported Beanstalk S3 policy

This commit is contained in:
brandizzi 2026-07-30 12:20:56 -03:00
parent 9966c22240
commit 4a6632f025
2 changed files with 40 additions and 107 deletions

View file

@ -203,12 +203,8 @@ The previous OIDC deployment remained fail-closed after Elastic Beanstalk
reported a generic `s3:GetObjectAcl` denial outside the account-owned source
prefix. AWS Support case `178526484500047` subsequently confirmed that
`UpdateEnvironment` checks objects in AWS-owned Elastic Beanstalk buckets
using the initiating role and requires the `elasticbeanstalk-*/*` resource
namespace. This policy adds only the denied ACL-read action on that namespace;
it intentionally does not copy the managed
`AdministratorAccess-AWSElasticBeanstalk` policy's broad `s3:Get*`,
`s3:Put*`, or `s3:Delete*` grants. Any later denial must be evaluated and
granted independently.
using the initiating role and requires the service-wide
`elasticbeanstalk-*` bucket/object namespaces.
The July 30 deployment of backend PR #41 then reached `UpdateEnvironment` and
failed on `ec2:DescribeVpcs`. Elastic Beanstalk performs this read-only network
@ -216,22 +212,25 @@ discovery using the initiating role, so the CDK policy includes that action
alongside the existing EC2 describe permissions. It remains resource `*`
because `DescribeVpcs` does not support resource-level permissions.
After that permission was deployed, the next exact rerun reached the S3 cleanup
phase and failed because Elastic Beanstalk could not delete one or more
temporary objects. The service does not expose the AWS-owned bucket/key in its
event, so `s3:DeleteObject` is scoped to the same AWS-confirmed
`arn:aws:s3:::elasticbeanstalk-*/*` object namespace as `GetObjectAcl`. The
policy does not grant wildcard S3 actions or access to non-Beanstalk buckets.
Successive exact reruns then reached S3 cleanup, the delegated CloudFormation
update, and the CloudFormation template fetch. The observed failures were
`s3:DeleteObject`, `cloudformation:UpdateStack`, and finally an opaque
CloudFormation `S3 error: Access Denied` after narrower object reads had been
added. Because AWS does not expose the AWS-owned bucket/key or exact internal
S3 read in that final error, the CDK now uses AWS Support's authoritative
UpdateEnvironment S3 set:
The following rerun passed both denials and reached the delegated
CloudFormation update, which required `cloudformation:UpdateStack`. That
mutation is limited to the single existing Elastic Beanstalk-managed stack ARN
for `shoc-backend-dev`; it cannot create stacks or update any other stack.
- `s3:Delete*`, `s3:Get*`, and `s3:Put*` on
`arn:aws:s3:::elasticbeanstalk-*/*`.
- `s3:GetBucket*`, `s3:ListBucket`, `s3:PutBucketPolicy`,
`s3:PutBucketPublicAccessBlock`, and `s3:PutBucketOwnershipControls` on
`arn:aws:s3:::elasticbeanstalk-*`.
Once `UpdateStack` was allowed, CloudFormation returned `S3 error: Access
Denied` while resolving the Elastic Beanstalk-managed template. The caller
therefore receives `s3:GetObject` on the same Beanstalk-only object namespace;
the policy still does not grant `s3:Get*` or access to other bucket names.
`s3:CreateBucket` remains excluded because this workflow targets an existing
application/environment and explicitly disables bucket creation. No S3 access
is granted to non-Elastic-Beanstalk bucket names. The CloudFormation mutation
remains limited to the single existing `shoc-backend-dev` managed stack ARN; it
cannot create stacks or update another stack.
The pinned deployment action can return success after Elastic Beanstalk emits a
fatal deployment event. The following workflow step therefore verifies that

View file

@ -9,7 +9,6 @@ const ENVIRONMENT_NAME = 'shoc-backend-dev';
const ENVIRONMENT_ID = 'e-hehnrqjjrt';
const ENVIRONMENT_STACK_NAME = `awseb-${ENVIRONMENT_ID}-stack`;
const REPO = 'Sea-Haven-Industries/shoc-backend';
const BUCKET_NAME = `elasticbeanstalk-${REGION}-${ACCOUNT_ID}`;
export class DeployDevStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: cdk.StackProps = {}) {
@ -17,19 +16,6 @@ export class DeployDevStack extends cdk.Stack {
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 bucketArn = `arn:aws:s3:::${BUCKET_NAME}`;
const runtimeVersionArn =
`${bucketArn}/resources/environments/${ENVIRONMENT_ID}` +
`/_runtime/_versions/${APPLICATION_NAME}/*`;
const embeddedExtensionArn =
`${bucketArn}/resources/_runtime/_embedded_extensions/` +
`${APPLICATION_NAME}/*`;
const environmentEmbeddedExtensionArn =
`${bucketArn}/resources/environments/${ENVIRONMENT_ID}` +
`/_runtime/_embedded_extensions/${APPLICATION_NAME}/*`;
const runtimeManifestArn =
`${bucketArn}/resources/environments/${ENVIRONMENT_ID}` +
'/_runtime/versions/*';
const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`;
const deployRole = new iam.Role(this, 'GithubDeployRole', {
@ -143,83 +129,31 @@ export class DeployDevStack extends cdk.Stack {
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
's3:ListBucket',
's3:CreateBucket',
's3:PutBucketOwnershipControls',
's3:GetBucketLocation',
's3:GetBucketPolicy',
],
resources: [bucketArn],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:GetObjectVersion', 's3:PutObject'],
resources: [`${bucketArn}/${APPLICATION_NAME}/*`],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
's3:DeleteObject',
's3:GetObject',
's3:GetObjectVersionAcl',
's3:PutObject',
's3:PutObjectVersionAcl',
],
// UpdateEnvironment copies the uploaded source bundle into this
// environment-specific runtime prefix, verifies the temporary copy,
// preserves its version ACL, and removes it after registration.
resources: [runtimeVersionArn],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:PutObject'],
// UpdateEnvironment materializes the application's embedded-extension
// manifest under the shared and environment-specific runtime prefixes.
resources: [embeddedExtensionArn, environmentEmbeddedExtensionArn],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject'],
// Elastic Beanstalk verifies the environment copy with HeadObject.
resources: [environmentEmbeddedExtensionArn],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:PutObject'],
// UpdateEnvironment reads the prior environment version manifest and
// writes its replacement under this environment-only runtime prefix.
resources: [runtimeManifestArn],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:DeleteObject', 's3:GetObject', 's3:GetObjectAcl'],
// UpdateEnvironment also checks and removes temporary objects in
// AWS-owned Elastic Beanstalk buckets. AWS Support case
// 178526484500047 confirmed that the caller's identity policy must
// cover the service-wide bucket namespace.
actions: ['s3:Delete*', 's3:Get*', 's3:Put*'],
// AWS Support case 178526484500047 confirmed that UpdateEnvironment
// reads, writes, versions, ACL-checks, and removes objects in both the
// account bucket and AWS-owned Elastic Beanstalk service buckets.
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',
],
// This is 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 dev.',