shoc-backend/infra/cdk/README.md
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

306 lines
16 KiB
Markdown

# 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.