fix(cdk): allow Beanstalk VPC discovery (#44)
Some checks failed
Validate and deploy dev / Validate deployable source bundle (push) Has been cancelled
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Has been cancelled

* fix(cdk): allow Beanstalk VPC discovery

* chore(ci): clarify backend check name

* fix(eb): allow temporary object cleanup

* fix(cdk): allow managed environment stack update

* fix(cdk): allow Beanstalk template read

* fix(cdk): apply supported Beanstalk S3 policy

* fix(cdk): allow load balancer discovery and cancel

* fix(cdk): allow Beanstalk resource discovery
This commit is contained in:
Alexandre Brandizzi 2026-07-30 12:40:06 -03:00 • committed by GitHub
parent 83ed6a1790
commit 47c3fff4ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 101 additions and 126 deletions

View file

@ -1,11 +1,34 @@
name: CI
name: Backend CI
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
with:
dotnet-version: "8.0.x"
solution: "SeaHavenIndustries.sln"
run-tests: true
build-and-test:
name: Build and test
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: backend-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore SeaHavenIndustries.sln
- name: Build
run: dotnet build SeaHavenIndustries.sln --no-restore --configuration Release
- name: Test
run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release

View file

@ -191,10 +191,11 @@ All commands run from `infra/cdk/`.
- Trust policy `StringEquals` matches the exact audience and subject above.
- The inline policy contains no `Resource: "*"` mutation action and no service
outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` /
`autoscaling`. CloudFormation discovery is limited to the single EB-managed
stack prefix. EC2 and Auto Scaling discovery use `Resource: "*"` only where
the IAM resource model requires it; Auto Scaling mutations are limited to
this environment's ASG name pattern.
`elasticloadbalancing` / `autoscaling`. CloudFormation discovery and
mutations are limited to the single EB-managed stack prefix. EC2, Elastic
Load Balancing, and Auto Scaling discovery use `Resource: "*"` only where the
IAM resource model requires it; Auto Scaling mutations are limited to this
environment's ASG name pattern.
The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must
hold the ARN output by this stack (`GithubDeployRoleArn`).
@ -203,12 +204,46 @@ 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
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.
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:
- `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-*`.
`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 next rerun cleared S3 and then required the read-only
`elasticloadbalancing:DescribeLoadBalancers` discovery action. Its failed
managed-stack update also required `cloudformation:CancelUpdateStack`; the
cancel action is scoped to the same single stack ARN as `UpdateStack`.
The subsequent rerun progressed into Auto Scaling and required
`autoscaling:DescribeLaunchConfigurations`. Because Elastic Beanstalk's
managed update workflow performs variable resource discovery, the role follows
the documented read-only discovery families for EC2, Elastic Load Balancing,
and Auto Scaling (`Describe*`). These grants expose metadata across the account
but do not authorize any mutation; write actions remain separately scoped.
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', {
@ -57,9 +43,12 @@ export class DeployDevStack extends cdk.Stack {
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'autoscaling:Describe*',
'ec2:Describe*',
'elasticbeanstalk:DescribeEnvironments',
'elasticbeanstalk:DescribeApplicationVersions',
'elasticbeanstalk:DescribeEvents',
'elasticloadbalancing:Describe*',
],
resources: ['*'],
}),
@ -94,6 +83,8 @@ export class DeployDevStack extends cdk.Stack {
'cloudformation:DescribeStackResources',
'cloudformation:DescribeStacks',
'cloudformation:ListStackResources',
'cloudformation:CancelUpdateStack',
'cloudformation:UpdateStack',
],
resources: [
`arn:aws:cloudformation:${REGION}:${ACCOUNT_ID}:stack/${ENVIRONMENT_STACK_NAME}/*`,
@ -101,29 +92,6 @@ export class DeployDevStack extends cdk.Stack {
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'ec2:DescribeAvailabilityZones',
'ec2:DescribeImages',
'ec2:DescribeSubnets',
],
resources: ['*'],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'autoscaling:DescribeAutoScalingGroups',
'autoscaling:DescribeScalingActivities',
],
resources: ['*'],
}),
);
deployRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
@ -141,82 +109,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:GetObjectAcl'],
// UpdateEnvironment also checks 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.',