shoc-backend/infra/cdk
Adam Moussa 6ef8e9ba2a
feat(terraform): complete dev environment adoption (SH-300) (#99)
* feat(terraform): complete dev environment adoption

* fix(terraform): preserve dev release permissions
2026-08-31 20:18:44 -03:00
..
app.ts chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /infra/cdk (#53) 2026-08-04 10:36:09 -03:00
cdk.json ci: automate dev backend deployment 2026-07-27 19:26:07 -03:00
deploy-dev-stack.ts feat(terraform): complete dev environment adoption (SH-300) (#99) 2026-08-31 20:18:44 -03:00
package-lock.json chore(deps): upgrade AWS CDK library and CLI 2026-08-25 05:38:14 -03:00
package.json chore(deps): upgrade AWS CDK library and CLI 2026-08-25 05:38:14 -03:00
README.md feat(terraform): complete dev environment adoption (SH-300) (#99) 2026-08-31 20:18:44 -03:00
tsconfig.json chore(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /infra/cdk (#53) 2026-08-04 10:36:09 -03:00

shoc-backend CDK

Dev deploy-role stack

The existing shoc-backend-deploy-dev stack owns exactly one thing in the shoc-backend AWS account (396287094661, us-east-1): the retained GitHub OIDC deploy role for dev. Automatic deployments are disabled while Terraform adoption proceeds; dev, staging, and prod releases require an explicit workflow_dispatch from the matching branch. The CDK stack remains until the role's CloudFormation ownership transfer completes.

Ownership boundary (deliberate)

CDK owns:

  • The IAM role githubdeploy-shoc-backend-dev.
  • Its OIDC trust relationship to arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com scoped to repo:Sea-Haven-Industries/shoc-backend:environment:dev.
  • Its least-privilege inline permissions policy.

CDK does not own, create, import, replace, or modify any of the following. They are referenced by exact identifier only and remain owned by their original provisioning path:

  • Elastic Beanstalk application shoc-backend
  • Elastic Beanstalk environment shoc-backend-dev
  • DNS, VPC, EC2, RDS, and existing service/instance roles
  • S3 bucket elasticbeanstalk-us-east-1-396287094661
  • Environment configuration / option settings
  • Database schema (migrations are applied by Elastic Beanstalk at deploy time, not by CDK)

The role is retained on stack deletion (DeletionPolicy=Retain, UpdateReplacePolicy=Retain) so an accidental teardown cannot orphan the trust or lock out deployments.

Least-privilege policy summary

The role grants only:

  • The three read-only Elastic Beanstalk actions used by deploy, wait, and rollback (DescribeApplicationVersions, DescribeEnvironments, and DescribeEvents). These use Resource: "*" because Elastic Beanstalk describe actions are not reliably constrained by resource ARN.

  • elasticbeanstalk:CreateApplicationVersion on application shoc-backend.

  • elasticbeanstalk:UpdateEnvironment on environment shoc-backend-dev only.

  • s3:ListBucket and s3:GetBucketLocation on elasticbeanstalk-us-east-1-396287094661 (the official action's ownership-safe bucket checks), s3:GetBucketPolicy for the policy inspection observed in attempt 11 of run 30448885838, plus s3:CreateBucket and s3:PutBucketOwnershipControls on the same bucket-level ARN. Under the shoc-backend/ object prefix only: s3:PutObject, s3:GetObject, and s3:GetObjectVersion, which the pinned official deployment action requires to validate the CreateApplicationVersion source bundle after upload.

  • s3:PutObject, s3:GetObject, s3:GetObjectVersionAcl, s3:PutObjectVersionAcl, and s3:DeleteObject on only elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_versions/shoc-backend/*. Elastic Beanstalk copies each uploaded source bundle into this environment-specific runtime prefix during UpdateEnvironment, verifies it with HeadObject (authorized by s3:GetObject), and removes the temporary copy after the version is registered. Attempts 1 through 4 of run 30448885838 exposed the exact source, destination, cleanup, and verification operations after the earlier ACL denial was resolved. CloudTrail recorded the exact s3:GetObject denial on attempt 4; attempt 6 then exposed the version-specific ACL read performed on the copied object; attempt 7 exposed the matching version-ACL write. The grant does not cover another environment, another application, source bundles, object content versions, non-version ACL mutation, tags, or retention.

  • s3:PutObject on only the two embedded-extension prefixes elasticbeanstalk-us-east-1-396287094661/resources/_runtime/_embedded_extensions/shoc-backend/* and elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_embedded_extensions/shoc-backend/*. After the runtime bundle copy and version-ACL operations succeeded, attempt 8 of run 30448885838 showed Elastic Beanstalk materializing the application's embedded-extension manifest at the application-specific shared prefix. Attempt 9 then showed the matching write into the exact dev-environment prefix. CloudTrail recorded both denied actions and object ARNs. The grant does not include reads, deletes, ACL mutation, another application, another environment, or another bucket.

  • s3:GetObject on only the environment-specific embedded-extension prefix above. Attempt 10 showed that Elastic Beanstalk verifies the materialized environment copy with HeadObject, which S3 authorizes through s3:GetObject. The shared embedded-extension prefix remains write-only.

  • s3:GetObject and s3:PutObject on only elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/versions/*. Attempt 12 showed Elastic Beanstalk reading the previous environment version manifest and writing its replacement under this exact dev-environment runtime prefix. The grant excludes deletes, ACL mutation, other environments, and application bundle content.

  • s3:GetObjectAcl on objects under the service-wide arn:aws:s3:::elasticbeanstalk-*/* namespace. AWS Support case 178526484500047 confirmed that UpdateEnvironment uses the initiating role to inspect objects in AWS-owned Elastic Beanstalk buckets, not only the account-owned source-bundle bucket. The wildcard is limited to one read-only ACL action and the Elastic Beanstalk bucket namespace; it grants no object content read, write, delete, bucket-management, IAM, or PassRole capability.

    s3:CreateBucket is part of the pinned aws-actions/aws-elastic-beanstalk-deploy@cfad3e5e... (v1.0.6) IAM contract even though the workflow sets create-s3-bucket-if-not-exists: "false". That input prevents the action's explicit bucket-creation helper; it does not remove the permission required by the subsequent Elastic Beanstalk update path. A live deployment confirmed this boundary: CreateApplicationVersion succeeded, then UpdateEnvironment was denied because the caller lacked s3:CreateBucket on the service bucket. The permission is scoped to that exact bucket-level ARN only (no object prefix, no wildcard resource), so it cannot create any other bucket.

    s3:PutBucketOwnershipControls was added after a second live deployment (run 30375409934) failed at UpdateEnvironment with AccessDenied for s3:PutBucketOwnershipControls on the same service bucket. That call is emitted by Elastic Beanstalk's UpdateEnvironment path after the source bundle upload succeeds; AWS classifies it as a bucket-level permission, so it is scoped to the same exact bucket-level ARN (no object prefix, no wildcard resource). It does not widen object-prefix permissions, does not grant PutBucketPolicy, PutBucketPublicAccessBlock, or any object-level write, and does not change create-s3-bucket-if-not-exists: "false".

    s3:GetBucketLocation was added after CloudTrail showed that run 30375409934 attempt 4 invoked it as githubdeploy-shoc-backend-dev/GitHubActions and was denied. It is scoped to the exact bucket-level ARN and grants no object access.

  • The six CloudFormation discovery calls observed across the failed OIDC and successful administrator deployments (DescribeStackEvents, DescribeStackResource, DescribeStackResources, DescribeStacks, GetTemplate, and ListStackResources) on the Elastic Beanstalk-managed stack awseb-e-hehnrqjjrt-stack, scoped to arn:aws:cloudformation:us-east-1:396287094661:stack/awseb-e-hehnrqjjrt-stack/*. These read-only calls are emitted by Elastic Beanstalk's UpdateEnvironment path under the GitHub deploy role. GetTemplate was added after run 30375409934 attempt 2 advanced past the S3 ownership-controls step and was denied on the EB-managed stack instance awseb-e-hehnrqjjrt-stack/112f77c0-7718-11f1-a1a9-0e48750aef13. CloudTrail then showed attempt 4 denied DescribeStackResources and ListStackResources on that same stack instance. CloudFormation stack ARNs carry a random GUID instance suffix, so the permission is scoped to that one stack-name prefix (/*) rather than a single instance ARN. The statement grants no CloudFormation mutation, no Resource: "*", and no access to any other stack. CDK does not own or mutate that stack; it is owned by Elastic Beanstalk and referenced by identifier only.

  • ec2:DescribeAvailabilityZones, ec2:DescribeImages, and ec2:DescribeSubnets as read-only account-level discovery queries. CloudTrail identified the GitHub deploy role as the caller during run 30375409934; attempt 5 confirmed the first two denials after DescribeSubnets was allowed. EC2 does not support resource-level constraints for these Describe actions, so IAM requires Resource: "*". No EC2 mutation action is granted.

  • The Auto Scaling discovery calls DescribeAutoScalingGroups and DescribeScalingActivities on Resource: "*" plus PutNotificationConfiguration, ResumeProcesses, and SuspendProcesses on only Auto Scaling groups whose name starts with awseb-e-hehnrqjjrt-stack-. These are the exact calls recorded during the successful administrator deployment. AWS supports resource-level constraints for all three mutations, so replacement ASGs remain covered without granting access to another environment. It grants no IAM mutation or PassRole, no RDS / Secrets Manager access, no EC2 mutation, and no administrator policy. The only non-EB/S3 mutations are the three deployment-process Auto Scaling calls, restricted to this environment's ASG name pattern. There are no wildcard mutation surfaces; the only service-wide object grant is read-only ACL metadata.

Prerequisites

  • Node >= 22.22.1 and npm.
  • AWS credentials authorized to create/inspect CloudFormation, IAM roles, and trust policies in account 396287094661.
  • The GitHub OIDC provider arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com must already exist in the account (created once, outside this stack).

Commands

npm ci                          # install pinned dependencies
npm run build                   # type-check / compile to dist/
npm run synth                   # synthesize the CloudFormation template
npm run diff                    # diff deployed stack vs local (requires AWS)
npm run deploy                  # deploy the stack (requires AWS)

All commands run from infra/cdk/.

Terraform ownership transfer

ManageGithubDeployRole deliberately has no default. Every CDK deployment must state the intended ownership phase:

# Before the controlled Terraform apply: install Retain on the role and policy.
npx cdk deploy shoc-backend-deploy-dev \
  --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=true

# After Terraform succeeds and live verification passes: relinquish ownership.
npx cdk deploy shoc-backend-deploy-dev \
  --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=false

Both deployments must use the same reviewed SHA. The first keeps the role and generated inline policy under CloudFormation while adding retention metadata. The second removes both resources from CloudFormation ownership while retaining them live for Terraform. After the second deployment succeeds, ManageGithubDeployRole=true must never be used again.

Omitting the parameter fails closed before deployment. If the true deployment rolls back, inspect the stack resources and live role/policy before retrying; retained resources can outlive a failed update and must not be cleaned up automatically.

CI integration

npm run synth is the deterministic local/CI validation. After synth, inspect cdk.out/shoc-backend-deploy-dev.template.json and verify the synthesized AWS::IAM::Role:

  • Trust policy StringEquals matches the exact audience and subject above.
  • The role, generated AWS::IAM::Policy, and role ARN output share the ManageGithubDeployRoleCondition; both resources use DeletionPolicy and UpdateReplacePolicy set to Retain.
  • The inline policy contains no Resource: "*" mutation action and no service outside elasticbeanstalk / s3 / cloudformation / ec2 / 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).

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 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 the exact immutable version label is active and healthy before smoke testing. Any mismatch fails and invokes rollback. This guard prevents false success; it does not make the unresolved OIDC deployment path release-ready.

The GitHub dev environment is an external release control and must restrict deployments to the dev branch. Required reviewers should be configured when the repository plan supports environment reviewers. The workflow also checks the exact branch before requesting an OIDC token.

Migration and recovery contract

The deployment bundle applies pending EF Core migrations before the new application starts. Migrations must therefore use an expand/contract sequence:

  • Expand changes must remain backward compatible with the previously deployed application version.
  • Destructive contract changes are deployed only after all application versions relying on the old schema have been retired.
  • A failed deployment restores the previous application version only. Database schema is not downgraded, and schema rollback is not claimed.

This contract preserves the usefulness of application-version recovery without misrepresenting it as a tested database downgrade.