mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
* feat(terraform): complete dev environment adoption * fix(terraform): preserve dev release permissions
306 lines
16 KiB
Markdown
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.
|