Deploy Roles & Environments
How OnRamp's CI deploy roles work, and exactly what to do to create a new environment, stand up its state backend, or change what a deploy role can do — without ever handing a contributor admin or running an infra apply locally.
Canonical sources. This runbook is the operator HOWTO. The executable truth lives in code — when a detail here and the code disagree, the code wins:
infra/ci-identity/boundary-policy.json— the shared permissions boundary.infra/ci-identity/identity-policy.ts—buildIdentityPolicy(the per-env grant).infra/ci-identity/envs.ts— the tier registry.infra/ci-identity/index.ts— how each tier is instantiated.infra/scripts/bootstrap-deploy-roles.ts— the one admin step.infra/ci-identity/deploy-role-policy.test.ts— the ABAC contract tests (the spec).infra/ci-identity/DESIGN-deploy-role.md— design rationale + the §6 per-service ABAC-vs-ARN/region/Deny table.
TL;DR — the mental model
Two layers, one ceiling.
A role can do something only if both its per-env identity policy and the one shared boundary allow it, and neither denies it. The boundary is the cap; the identity is the grant.
environment=<tier> PrincipalTag.
Every role is tagged with a constant tier (previews, prod, gdpr-prod, stage, demo). ABAC self-scopes mutations to same-tier resources. This is not the app's per-PR ENVIRONMENT_NAME (preview-9412).
Own bucket + KMS key.
Each tier deploys with its own Pulumi state bucket + secrets key. A role gets a plain resource-scoped grant on its own — real blast-radius isolation, no cross-env state reads.
No admin for contributors, ever. Admin is used exactly once per environment, by an operator, to run one bootstrap script. After that, CI assumes the least-privilege role via GitHub OIDC — no stored AWS keys, no local infra applies.
How the pieces fit
The boundary (onramp-deploy-boundary) is a single customer-managed policy, env- and region-agnostic. It self-scopes via aws:ResourceTag/environment == ${aws:PrincipalTag/environment}, enforces tag-on-create, and carries the anti-escalation rules (see Security invariants). You almost never edit it.
createDeployRole({ env, region, subjects, … }) mints one role onramp-deploy-<tier> — bounded, environment=<tier> tagged — and attaches the identity policy buildIdentityPolicy(...) returns. "Another environment" = one more registry entry + one factory call.
onramp-pulumi-state-<tier>-<account> + alias/onramp-pulumi-secrets-<tier>. Created by the bootstrap script in the tier's own region. The role gets a plain ARN grant on its own bucket/key only.
bun run bootstrap.roles ensures the seed bucket, optionally provisions the per-env state plane, imports the GitHub OIDC provider, and runs pulumi up on the identity-tier stack. The only place admin is needed.
The chicken-and-egg (and how it's solved)
Pulumi needs a state backend before it can create anything — including the buckets and roles that are the deploy-role system. The resolution: one bucket is created imperatively; everything else is Pulumi-managed from it.
flowchart TD
OP["Operator (admin) · bun run bootstrap.roles"] --> SEED["1. Seed state bucket + KMS<br/>onramp-pulumi-state-ACCT (us-west-2)"]
OP --> PENV["2. Per-env state plane<br/>onramp-pulumi-state-TIER-ACCT + KMS"]
OP --> OIDC["3. GitHub OIDC provider (imported, never recreated)"]
SEED --> STACK["4. pulumi up · identity-tier stack<br/>(its OWN state lives in the seed bucket)"]
OP --> STACK
STACK --> BND["Boundary policy<br/>onramp-deploy-boundary"]
STACK --> ROLE["Deploy role<br/>onramp-deploy-TIER (bounded, environment=TIER)"]
OIDC --> ROLE
ROLE -->|CI assumes via OIDC| DEPLOY["Env deploys<br/>Lambda · AgentCore · Cognito · KVS"]
PENV -->|state backend for| DEPLOY
style STACK fill:#e6f3ff,color:#000The key move: the identity-tier stack stores its own state in the seed bucket, not in a per-env bucket. So the seed bucket is the single imperative prerequisite; the boundary, the roles, and the per-env state buckets are all Pulumi resources created by that stack. Per-env buckets hold the tier's deploy state (Lambda, AgentCore, …), never the identity-tier stack's own state.
What stops source from outrunning the apply
Because this stack converges only by a manual pulumi up, merging a trust-policy change does not apply one. A PR whose source is internally consistent — workflow binding and declared subject changed together — still breaks every deploy through that role until an operator runs the apply, and nothing about the merge says so.
Every change under infra/ci-identity/** requires a manual apply after the code is final and before merge. Run it from the exact worktree and branch whose declarations should be live. The stack covers every tier in the shared AWS account, and Pulumi deletes managed resources that are absent from the branch being applied.
Four checks make the divergence unmergeable instead. Each is a link between two things that must agree; none of them can be satisfied by editing only one side.
| # | Link | Where it runs | Needs |
|---|---|---|---|
| 1 | a job's environment: binding == its row in .github/scripts/oidc-env-bindings.ts (verbatim), and every binding has a row | bun test .github/scripts/ — Test CI Scripts, every PR | — |
| 2 | that registry's required subjects ⊆ what envs.ts / migrate-starter-role.ts declare | bun test in infra/ — Infra CI | — |
| 3 | every declared OIDC subject is live, each admitting statement pins aud + the expected provider, and no statement admits callers it cannot scope | Infra CI → Declared OIDC trust is applied | onramp-ci-oidc-verify |
| 4 | vars.DUPLO_TENANT_NAME is non-empty at the point of use | .github/actions/duplo-aws-jit, in-job | — |
Link 3 is the one that catches an unapplied stack, and it is red until you apply: a PR that adds a subject stays failing until an operator runs the apply below, then goes green on a re-run of the job with no new commit. That inversion is the mechanism — it turns the apply into a merge prerequisite rather than a step in this runbook that someone has to remember. It fails on divergence in either direction, so removing a subject from source also requires the apply before merge: otherwise the repo claims a trust relationship is gone while AWS still honours it.
It reports three distinguishable causes, and the fix differs:
- declared but not applied (or the role is absent) → run the apply.
- applied but no longer declared → run the apply; until you do, a trust relationship the repo says is gone is live.
- admits callers the check cannot scope to a subject → do not just apply. An
Allow sts:AssumeRoleWithWebIdentitystatement with nosubcondition admits every workflow in every repository, and one federated to another IdP or with a differentaudis outside what these roles are supposed to trust. Any of those means a live over-broad trust policy, which is a person's problem before it is Pulumi's. Note what this rules out: a subject-list comparison alone would score such a policy as agreement, because an unconditioned statement contributes no subject to compare.
What link 3 does not compare: the rest of the trust document — other condition keys, tags, session policies. A change confined to those and merged without an apply is not caught.
Link 4 covers what an IAM comparison structurally cannot. The agent-deploy jobs assume no AWS role — they take Duplo JIT credentials keyed on a GitHub Environment variable — so a binding to an Environment nobody has provisioned reads as an empty tenant, not an AccessDenied. It is also the closest in-job signal that an Environment was auto-created blank rather than provisioned, which matters because a blank one carries no deployment branch/tag allow-list either — the exact shape a brand-new tier is in before step 1 of "Add a new environment" below has run.
Known gap, deliberate. Nothing here verifies the GitHub side directly — that a tier's Environment exists with the allow-list ALLOWED_REFS_BY_ENV declares, or that its variables are set. Reading either back needs a credential GITHUB_TOKEN does not carry (administration is not one of the permissions: keys, and variables need a fine-grained token), so closing it means adding a GitHub App or PAT to store and rotate. What stands in for it: link 1 refuses a binding to an env with no ALLOWED_REFS_BY_ENV row (so nothing can bind an env that nothing provisions), and link 4 trips on the blank-Environment signature. Neither proves the allow-list is present on a prod-tier Environment — running bootstrap-agent-environments.ts before a new tier's binding ships is still a real security prereq, not just an availability one.
Add a new environment
Adding an environment is a one-line registry edit plus an operator standing up its state plane. Convention derives everything else from the tier name — the state bucket, the KMS key, the role ARN, the CI backend URL.
TL;DR. A new env X is the one line { env: "X" } in envs.ts — presence in the registry means live (there is no enabled flag; delete the line to retire a tier). Order matters: an operator bootstraps X's state plane first, then the one-line entry lands, then pulumi up creates the role. (gdpr-prod adds region: "eu-west-1" + --region eu-west-1 — the one tier that deviates.)
1 · Bootstrap the prereq (admin)
A tier's KMS alias must exist before any pulumi up resolves it, so the state plane is the prerequisite — provision it first, with no role yet:
AWS_PROFILE=onramp-admin bun run bootstrap.roles --env-tag demo --no-pulumi-upThe bucket name + KMS alias are derived from --env-tag (the same convention index.ts uses) — nothing to paste. This provisions the per-env bucket + KMS key (tagged environment=demo), ensures the seed bucket + the GitHub OIDC provider, and stops before pulumi up (the role is created in step 3, once the entry exists).
gdpr-prod adds --region eu-west-1 — the one tier that deviates: … --env-tag gdpr-prod --region eu-west-1 --no-pulumi-up. KMS keys are regional and EU data residency requires the EU tier's state + secrets in-region; the script fails closed if you omit it. The seed bucket + identity-tier stack stay in us-west-2 regardless.
Change what a deploy role can do
The first question is always which layer — one tier, or all of them?
Grant or tighten a single tier's permissions → edit buildIdentityPolicy in infra/ci-identity/identity-policy.ts. This is the common case (e.g. previews needs a new S3 prefix, AgentCore action, or SSM path).
- Add/adjust the statement. Keep it within the boundary ceiling — a grant the boundary doesn't also allow is dead (intersection = nothing).
- Add a contract to
deploy-role-policy.test.tsasserting the new allow and the matching cross-env deny. cd infra && bun test→ green, thenpulumi uptheidentity-tierstack.
No boundary edit, no new ticket churn — this is the path you want 90% of the time.
State buckets & KMS
- Seed (shared, us-west-2):
onramp-pulumi-state-<account>+alias/onramp-pulumi-secrets - Per-tier:
onramp-pulumi-state-<tier>-<account>+alias/onramp-pulumi-secrets-<tier>
Per-tier plane is created in the tier's region (--region). gdpr-prod ⇒ eu-west-1 (guard-enforced). Seed + identity-tier stack stay us-west-2.
Dedicated bucket ⇒ the name is the isolation (role gets stacks/*). Shared seed bucket ⇒ the grant narrows to the tier's stacks/<prefix>/* so a role can't read co-located stacks (incl. the identity-tier stack that defines the roles).
The per-tier KMS key is the Pulumi secrets provider. It must carry environment=<tier> so the boundary's ABAC KMS allow admits it. The bootstrap script tags it for you.
Verify locally
The ABAC evaluator test is a deps-free IAM simulator over the realbuildIdentityPolicy ∩ the boundary — it's the executable spec for every claim in this runbook.
cd infra
bun install # standalone package
bunx tsc --noEmit -p tsconfig.json
bun test # cross-env, escalation, tag-integrity, region, cutover contractsYou can't pulumi up without admin + the seed backend, so local verification stops at tsc + tests — which is exactly why the contract suite is exhaustive.
Security invariants — never weaken these
These are the load-bearing properties. Any boundary/identity change must keep all of them; the contract tests enforce them and will fail CI if you regress one.
CreateRolemust carry the boundary — minted roles can't escape the cap.- No
iam:PutRolePolicy— no inline-policy escalation. AttachRolePolicyis allowlisted — only the named managed policies.PassRoleis path- + service-scoped — no passing a privileged role.- Tag-on-create enforced — a resource can't be created outside its tier tag, and the tag can't be stripped or re-tagged to another tier.
- No no-op conditions — only use a tag condition on a service that honors it.
- Per-env state isolation — a role reaches only its own state bucket/key.
- Boundary + OIDC provider are tamper-protected — roles can't edit the policy that bounds them, or the provider that authenticates them.
Troubleshooting
Decide the layer: if the boundary denies it, every tier is blocked (raise the ceiling — boundary). If only this tier, it's the identity grant. Reproduce it as a contract test first; fix the policy the test points at.
Almost always tag-on-create: the resource is being made without environment=<tier> (or with the wrong tier). Ensure the deploy stamps the tag, or that deployRoleBoundaryArn is set so minted roles inherit the boundary.
The bootstrap guard fires if --env-tag gdpr-prod isn't paired with --region eu-west-1. The boundary already spans both regions; per-tier region confinement is the identity's region arg, not the boundary.
The contract suite is the spec, not an afterthought. A red test means the shipped policy changed behavior — fix the policy, not the assertion, unless the behavior change is intended (then update the contract in the same change).
Out of scope
Retiring deploy.py and migrating prod AgentCore (Knowledge Bases, OpenSearch Serverless, runtime roles) onto Pulumi is a separate effort. This deploy-role system is the safe-deploy enabler for it: once a prod role is stamped here, that migration deploys through it instead of with admin.