Airframe · Glidepath · Spec · Keyless cloud deploys

No stored AWS key for cloud deploys

Hangar reached AWS with two access keys: passwords for machines that worked from anywhere until someone deleted them, one of them shared by every app. I wanted them gone, not rotated. Doing that also turned up a way for Crossplane to make itself an AWS administrator, and the switch itself deleted a function before I fixed the composition that allowed it. This page explains all three for someone meeting these ideas for the first time.

Spec October 16, 2026Status phases 0 to 4 live for Lambda, Oct 16, 2026; ECS keyless the same day; the old keys are deleted by hand; Azure nextDocs Airframe cloud credentials · Glidepath cloud deploy identity
Lambda keyless · Oct 16, 2026

Crossplane and Lambda deploys keep no AWS key.

Why: two keys that never expired

Hangar uses AWS in two ways. Crossplane builds what an app needs there: an image registry, a Lambda function, a public web address for it. Glidepath, the delivery pipeline, then ships each new version of the app's code into that function. Each needed an AWS identity, and each got an access key: a username and password for a machine.

A key like that has three problems. It never expires, so a leaked copy works until someone notices. It works from anywhere, so a copy on a laptop is as good as the original. And the deploy key was shared by every app, so a leak from one app's pipeline reached every other app's functions. Rotating keys shortens the window; it doesn't close it.

From two stored keys to earned one-hour accessBefore, each AWS identity was a key stored in Infisical, copied into a Kubernetes Secret and readable by any pod that mounted it. After, kiac-dev signs a short-lived token for the pod, and AWS STS trades it for one hour of one role.BEFORE · TWO KEYS THAT NEVER EXPIRE, COPIED AROUNDInfisicaltwo projectsKubernetes Secretapp-secrets, provider credsPodsevery pipeline stepAWS IAM userskeys work from anywhereAFTER · NOTHING STORED, NOTHING THAT LIVES PAST AN HOURkiac-dev issuerpublic keys onlyDeploy podtoken from kubeletAWS STStrusts the issuerSIGNS1 HOUR · ONE ROLErole trust: issuer, audience, one ServiceAccount
Figure 1The workload-identity pattern a managed Kubernetes service gives you for free, built by hand for self-managed clusters.

How it works, in plain terms

Think of a building with a front desk. The old way handed each contractor a master key to keep. The new way gives them nothing to keep. They arrive with a badge their employer printed that morning, the desk checks it against the employer's published sample, and hands back a visitor pass for one floor that expires in an hour.

The employer is the cluster: Kubernetes gives every pod a signed identity card, a ServiceAccount token. The published sample is kiac-dev's public signing keys, at hangarplatform.dev/oidc/kiac-dev: anyone can check a signature with them, nobody can make one. The front desk is AWS STS, which checks the token against an IAM role's rules about who may use it, and returns credentials that last an hour. The industry name is web identity federation; on Amazon's own Kubernetes service it's called IRSA.

Trading a ServiceAccount token for AWS accessThe deploy pod gets a signed token from kiac-dev, sends it to AWS STS with the role it wants, STS checks it against the published public keys and the role's rules, returns a one-hour session, and the pod updates its own function.SIGNED TOKENTOKEN + ROLEGET KEYSPUBLIC KEYSCHECK CLAIMS1-HOUR SESSIONUPDATE CODEDeploy podglidepath-cloud-deployerkiac-devthe issuerPublished keyshangarplatform.devAWS STSsts.amazonaws.comLambda + ECRthis function only
Figure 2The pod never holds anything that outlives it. AWS caches the public keys, so they aren't fetched on every request.

What was built

The trust chain behind keyless deployskiac-dev publishes its public signing keys; an AWS identity provider trusts them; Crossplane assumes its own role and creates each function's deploy and execution roles inside a permissions boundary; the deploy step assumes its function's deploy role.KIAC-DEV CLUSTERPUBLIC WEBAWS ACCOUNTPERMISSIONS BOUNDARYKEYSREADVERIFIESASSUME ROLEASSUME ROLECREATESCREATESkiac-dev API serversigns SA tokensCrossplane providersSA provider-aws-*Deploy step · JobSA glidepath-cloud-deployerPublished keys/oidc/kiac-devIdentity providertrusts the issuerhangar-crossplane-kiac-devbuilds the infrastructureglidepath-deploy-<fn>this repo · this function<fn>-execruns the function
Figure 3Two kinds of workload, each with its own role. Everything Crossplane creates sits inside the boundary.
WhoRuns asAssumesCan do
Crossplane's AWS providersprovider-aws-*hangar-crossplane-kiac-devcreate, observe and delete what the compositions describe
Glidepath's deploy stepglidepath-cloud-deployerglidepath-deploy-<fn>push to this function's registry, update this function's code
The target's bootstrap Jobglidepath-cloud-deployerglidepath-deploy-<fn>the same, once, to seed the first image
Every other pipeline steppipeline-runnernothingno AWS access
Decision

The target creates each deploy role

The Lambda target already creates the function, its registry and its execution role, so it creates the deploy role beside them and deletes it with them. A new app needs no IAM step at all.

Decision

A dedicated ServiceAccount

Every pipeline step runs as pipeline-runner, including tests that run the app's own code. Only the deploy step and the bootstrap Job run as glidepath-cloud-deployer, the one subject the role trusts.

Decision

The account, stated once

The AWS account lives in the fleet's clusters.yaml. The compositions and every app's chart read it from there; no app repeats it in cicd.yaml.

Options considered

OptionVerdictWhy
Web identity federationChosenNothing stored, nothing new in the request path, one role per function, and the same pattern every managed Kubernetes uses.
Rotate the keysRejectedShortens the window but keeps it, adds a process someone must run, and keeps one key for every app.
Short-lived keys from a secrets storeFallbackReal leases, but the store keeps a standing credential that can mint them, and every deploy depends on it being up.
IAM Roles AnywhereRejectedTrusts X.509 certificates instead of tokens: a certificate authority to run and certificates to hand out, for the same result.
Trust pipeline-runnerRejectedSimpler, but any step that runs the app's code could reach AWS.

What checking the permissions turned up

Finding 0.1

Crossplane could mint an administrator

Its policy let it create any role ending in -exec, decide who may use it and attach any rules. Together that's a way to create a full administrator. Apps couldn't reach it, but a bug in a composition could.

Phase 0

A ceiling on every created role

A permissions boundary caps a role whatever its own rules say. Crossplane's policy now refuses to create a role, or add rules to one, unless the boundary is attached, and nothing may remove it.

Finding 0.2

Labels could be forged

Kubernetes lets any pod use any ServiceAccount in its namespace, and labels on a pipeline run are copied onto its pods. So the admission policies decide by who created the pod, which Kubernetes records and a client can't fake.

Six guardrails around a keyless deploySix independent checks: the admission policy and the short-lived token in Kubernetes, then the trust policy, the role policy, the permissions boundary and Crossplane's own policy in AWS.01Admission policywho may run as glidepath-cloud-deployer02Short-lived token1 hour · audience sts.amazonaws.com03Trust policythis issuer · this audience · this ServiceAccount04Role policythis repository · this function · no deletes05Permissions boundarythe most any created role can ever do06Crossplane's own policycan't create a role without the boundaryKUBERNETESAWS IAMA DEPLOY PASSES EVERY LAYER
Figure 4The boundary (05) was added because scoping this work showed Crossplane could create an administrator.

The migration

Switching every app at once would break any deploy that runs before its role exists, so for the switch the deploy step kept the old key as a fallback and logged which path it took. Once every app had deployed through its role and no cluster held an AWS key any more, the fallback was removed (phase 4). Now a deploy either assumes its role or stops and says what is missing.

How the deploy step gets its AWS accessThe deploy step checks that the namespace has the account ConfigMap, that the pod's token belongs to glidepath-cloud-deployer and that STS accepts the function's role. If all three hold it assumes the role; if one fails the deploy stops with an error naming that piece. There is no stored key to fall back to.YESYESYESNONONODeploy step startsAccount ConfigMapin the namespace?Token belongs toglidepath-cloud-deployer?STS acceptsthe role?Assume the roleglidepath-deploy-<fn>Deploy failsnames clusters.yamlDeploy failsnames taskRunSpecsDeploy failsnames the deploy Role
Figure 5Until phase 4 a failed check fell back to the app's stored key. That covered the minutes between switching an app over and Crossplane creating its role, and was removed once every app had moved.

Phase 0 · close the hole

Live

Permissions boundary and Crossplane policy v3; every composed role carries the boundary.

Phase 1 · a deploy role per function

Live

The Lambda target creates glidepath-deploy-<fn>; its bootstrap Job uses it.

Phase 2 · Glidepath assumes it

Live

The ServiceAccount, the account ConfigMap, the deploy step and promote flows.

Phase 3 · who may use it

Live

Four admission policies, 16 of 16 live cases. The first real deploy showed they also refused Tekton's signing and history services, which only add a note to a run; fixed the same day.

Phase 4 · retire the key

Live

The fallback and the scaffold's AWS secrets are gone, and a test fails the build if the deploy step ever reads a stored key again. The old Infisical keys and IAM users are deleted by hand.

ECS

Live

Since Oct 16, 2026 the ECS target composes an ECR repository and a deploy role too, and the deploy assumes it with a ServiceAccount token and copies the image into ECR. No stored key, and no app-secrets mounted.

Azure

Planned

Container Apps still log in with a stored service principal secret. Next: workload identity federation.

The incident: the switch deleted a function

Incident: smoke-fn's function deleted and re-createdOn October 16, 2026 (UTC) the switch merged at 17:05, the function, its URL and its permission were deleted at 17:16, the function came back from the placeholder at 17:22, and the fix merged at 17:27.17:0017:0517:1017:1517:2017:2517:3017:3517:05 · switch mergedregistry names the account17:16 · function deletedwith its URL and permission17:22 · function backplaceholder image, new URL17:27 · fix mergednever drop what exists
Figure 6Spaced to scale. The function was gone from 17:16:32 to 17:22:45.

Crossplane works by comparison: each time it looks at an app, it lists what should exist and deletes anything no longer on the list. Phase 1 made the bootstrap Job wait for the new deploy role. For one pass the role didn't exist, so the finished Job dropped off the list and was deleted. An older rule said the function exists only once the Job has succeeded, so on the next pass the function, its URL and its permission dropped off too, and were deleted in AWS. They came back six minutes later from the placeholder image, with a new web address.

The fix keeps anything that already exists on the list, whatever happens to the conditions it was created under. Two new tests replay both steps; run against the old composition, they fail the way production did.

A condition may decide when to create something. It must never decide whether to keep it.

How I know it works

CheckResult
Crossplane's roleAssumed from a real provider token; the default ServiceAccount, a wrong audience and the Azure provider were refused.
A real create and deleteAn empty registry created, Ready and deleted through the role; AWS confirmed it gone.
Permissions boundaryAdded to the existing execution role and on both new deploy roles, which policy v3 only allows with the boundary.
Bootstrap JobRan as glidepath-cloud-deployer and logged in to the registry through the deploy role, with no key mounted.
Admission policies16 of 16 live cases, 12 for creating objects and 4 for changing them, each test object checked to contain what its case claims.
A real deploysmoke-fn promoted to production (promote-prod-0-deploy-cc5kj): the deploy step ran as glidepath-cloud-deployer, logged credentials: assuming …/glidepath-deploy-glidepath-smoke-fn-prod (web identity, no stored key) and updated the function.
With no key path at allAfter phase 4, the same promotion re-run (promote-prod-0-deploy-t4n84): the deploy step had no app-secrets volume, assumed the role, updated the function, and was signed and recorded.

The page's numbers are from kiac-dev and AWS account 700017004810 on October 16, 2026. Pull requests: hangar#55, glidepath#114 to #116, airframe#87 to #90, gitops-cluster-dev#96 to #108, apron#30 and #31, and for phase 4 glidepath#118 and airframe#92 and #93; documentation in airframe#91, glidepath#117 and hangar#56.