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 / destroy assume the default hnb659fds CDK 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 needs sts:AssumeRole on those bootstrap roles — not direct cloudformation:* / 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 historical AccessDenied failures.

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

  1. The reference policy lives at apps/infra/policies/deploy-role.reference-policy.json.
  2. 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.
  3. 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.
  4. Ensure the account is CDK-bootstrapped with the default qualifier so the sts:AssumeRole tier works.
  5. 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/*).