Deploy Role Permissions
The CI/CD pipeline assumes a single OIDC deploy role (defaulted in .github/actions/aws-setup/action.yml). The deploy orchestrator and its lifecycle hooks call AWS before and around cdk deploy, so a role scoped only for CDK hits AccessDenied partway through the pipeline — and each failure costs a full deploy cycle to diagnose. This guide lists every action the deploy role needs, grouped by the lifecycle stage that issues it, and links a reference least-privilege policy you attach to your own role.
The starter does not create the IAM role: it is OIDC/account/trust-specific. On the WS.Eng shared account the role already exists and carries these permissions. A fork on a dedicated account creates its own role (trust policy for GitHub OIDC) and attaches the reference policy.
The permission model
The policy has two tiers.
- Tier 1 — CDK via bootstrap roles.
cdk synth/deploy/import/destroyassume the defaulthnb659fdsCDK bootstrap roles (cdk-hnb659fds-{deploy,file-publishing,lookup,image-publishing}-role-<account>-<region>) to do all CloudFormation stack CRUD, asset upload, and stack-resource creation (Lambda, API Gateway, DynamoDB, Cognito, CloudFront, the IAM roles for those resources). The deploy role only needssts:AssumeRoleon those bootstrap roles — not directcloudformation:*/s3:*/iam:*for the stack itself. Prerequisite: the account must be CDK-bootstrapped with the default qualifier (cdk bootstrap); this policy assumes that. - Tier 2 — direct SDK/CLI calls as the OIDC role. The orchestrator, the pre/post-deploy and pre/post-destroy hooks, and the in-pipeline workspace scripts (
update-env,upload-frontend,create-db-schema, the Secrets Manager fetch) call AWS with the OIDC role's own credentials, not the bootstrap roles. These need explicit grants. This tier is the gap that produced the historicalAccessDeniedfailures.
Required actions by lifecycle stage
Pre-CDK seeding (seedAllReservations)
Runs before cdk synth in the deploy orchestrator (apps/infra/scripts/orchestrators/deploy.orchestrator.ts).
| Action(s) | Called by | Purpose |
|---|---|---|
cloudfront:ListOriginRequestPolicies, cloudfront:CreateOriginRequestPolicy |
apps/infra/policies/origin-request-policies.service.ts:50,81 |
Content-address and create the shared CloudFront origin-request policies |
ssm:PutParameter, ssm:GetParametersByPath |
apps/infra/utils/ssm-reservation.util.ts |
Reserve policy refs under the account-wide /wseng/auto-policy-refs/ namespace |
CDK synth
| Action(s) | Called by | Purpose |
|---|---|---|
ec2:DescribeVpcs, ec2:DescribeSubnets, ec2:DescribeRouteTables, ec2:DescribeAvailabilityZones, ec2:DescribeVpnGateways, route53:ListHostedZones, route53:ListHostedZonesByName |
Vpc.fromLookup / HostedZone.fromLookup context providers in apps/infra/constructs/backend.construct.ts |
Resolve VPC and hosted-zone context at synth |
sts:GetCallerIdentity |
CDK CLI | Account/identity check |
ssm:GetParameter on /cdk-bootstrap/hnb659fds/version |
CDK CLI | Read the bootstrap version before assuming bootstrap roles |
Context lookups are skipped when apps/infra/cdk.context.json already caches the values, but the VPC lookup has no committed entry, so the EC2 describes fire on a fresh account.
CDK deploy / import / destroy
| Action(s) | Called by | Purpose |
|---|---|---|
sts:AssumeRole on cdk-hnb659fds-* |
deploy.orchestrator.ts:44, apps/infra/utils/import-log-groups.util.ts:141, apps/infra/scripts/orchestrators/destroy.orchestrator.ts:33 |
Assume bootstrap roles for all CloudFormation and asset work |
Pre-deploy hook (log-group import)
| Action(s) | Called by | Purpose |
|---|---|---|
logs:DescribeLogGroups, logs:DeleteLogGroup |
apps/infra/utils/cloudwatch-logs.util.ts:28,64 |
Discover and (ephemeral only) delete orphaned log groups |
cloudformation:ListStackResources |
apps/infra/utils/import-log-groups.util.ts:202 |
Detect which log groups are already CFN-managed |
Post-deploy hook
| Action(s) | Called by | Purpose |
|---|---|---|
cloudformation:DescribeStacks |
apps/infra/hooks/post-deploy.hook.ts:45 |
Read stack outputs (API URL, user pool, etc.) |
s3:PutObject, s3:ListBucket |
apps/infra/utils/config.util.ts |
Publish environment.json to the ws-mono-st-{integration,production}-assets bucket |
cognito-idp:AdminCreateUser, cognito-idp:AdminSetUserPassword, cognito-idp:ListUsers, cognito-idp:DescribeUserPool |
apps/infra/integrations/cognito.service.ts |
Seed test users; copy users into ephemeral envs (DescribeUserPool filters attrs the target schema does not declare) |
dynamodb:Scan, dynamodb:BatchWriteItem |
apps/infra/integrations/dynamodb.service.ts |
Copy seed data into ephemeral envs |
ssm:GetParameter, ssm:PutParameter |
apps/infra/hooks/post-deploy.hook.ts |
Idempotent test-credential and seed-status parameters |
Frontend upload (upload-frontend)
| Action(s) | Called by | Purpose |
|---|---|---|
s3:ListBucket, s3:GetObject, s3:PutObject, s3:DeleteObject |
apps/infra/integrations/s3.service.ts:51 |
Sync the built frontend to the frontend bucket (diffing needs GetObject) |
cloudfront:CreateInvalidation |
apps/infra/integrations/cloudfront.service.ts:13 |
Invalidate the CDN after upload |
ssm:PutParameter |
apps/infra/scripts/upload-frontend.script.ts:65 |
Record the last deployed frontend commit |
Environment sync (update-env)
| Action(s) | Called by | Purpose |
|---|---|---|
cloudformation:DescribeStacks |
apps/infra/scripts/update-env.script.ts:167 |
Read Lambda env vars from stack outputs |
secretsmanager:GetSecretValue |
apps/infra/scripts/update-env.script.ts:192 |
Read whitelisted secrets |
ssm:GetParameter |
apps/infra/scripts/update-env.script.ts:235-237 |
Read seeded test credentials |
Secret fetch (aws-setup) and DB schema (create-db-schema)
| Action(s) | Called by | Purpose |
|---|---|---|
secretsmanager:GetSecretValue |
.github/actions/aws-setup/action.yml:33 |
Load environment secrets into the job |
lambda:InvokeFunction on wseng-db-helper |
.github/actions/create-db-schema/action.yml:38 |
Provision the Postgres schema for the environment |
Destroy hooks (post-destroy)
| Action(s) | Called by | Purpose |
|---|---|---|
s3:DeleteObject |
apps/infra/utils/config.util.ts:55 |
Remove the environment's published config |
ssm:DeleteParameter |
apps/infra/hooks/post-destroy.hook.ts:71, apps/infra/utils/ssm-reservation.util.ts |
Delete app parameters and release reservations |
logs:DescribeLogGroups, logs:DeleteLogGroup |
apps/infra/utils/cleanup-log-groups.util.ts |
Remove CDK custom-resource log groups |
cloudfront:GetOriginRequestPolicy, cloudfront:DeleteOriginRequestPolicy |
apps/infra/policies/origin-request-policies.service.ts:149,150 |
Sweep unreferenced shared policies |
Post-deploy tests (test-support, optional)
These run under the same role only when the post-deploy test suite runs. If you do not run that suite, they are not required for a green deploy.
| Action(s) | Called by | Purpose |
|---|---|---|
cognito-idp:AdminDeleteUser |
packages/api-tests/utils/cleanup.util.ts |
Remove test users after a run |
dynamodb:Query, dynamodb:DeleteItem |
packages/api-tests/utils/cleanup.util.ts |
Clean up test rows |
lambda:InvokeFunction on the wipe-runner |
packages/api-tests/utils/cleanup.util.ts |
Wipe Postgres test data |
Applying the reference policy
- The reference policy lives at
apps/infra/policies/deploy-role.reference-policy.json. - Attach it to the OIDC deploy role your CI assumes (the ARN defaulted in
.github/actions/aws-setup/action.yml), as an inline or customer-managed policy. - The role itself and its trust policy (GitHub OIDC federation) are out of scope for the starter — create them per your account's OIDC setup.
- Ensure the account is CDK-bootstrapped with the default qualifier so the
sts:AssumeRoletier works. - On the WS.Eng shared account no action is needed: the shared role already carries these permissions.
Placeholders and account-wide identifiers
For a dedicated account, replace:
856284715153— the AWS account id.us-east-1— the region.ws-mono-st— the app short-name; this is auto-replaced by the sync tool in downstream forks, so no manual edit is needed on a normal fork.
Do not rename (these are account-wide, shared across all WS.Eng projects):
/wseng/auto-policy-refs/*— the SSM reservation namespace for shared CloudFront policies.wseng-db-helper— the shared DB-provisioning Lambda.
See docs/specs/naming-replacements.spec.md for the full identifier hygiene rules.
Least-privilege caveats
A few actions require Resource: "*" because AWS has no resource-level support for them:
- CloudFront origin-request-policy list / create / get / delete.
- EC2 and Route 53 describe / list context lookups.
sts:GetCallerIdentity.
Every other statement is scoped to app-prefixed ARNs (ws-mono-st-*, /ws-mono-st/*) or the shared reservation namespace (/wseng/auto-policy-refs/*).