# 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 ```bash 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: ```bash # 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.