Crossplane
What runs
Crossplane in the home cluster, with the AWS S3, KMS and IAM providers. AWS resources are Kubernetes objects, deployed by Argo CD like everything else.
Projects. A project is a bucket and everything that uses it. Each one asks for a
BackupStore. Crossplane builds the bucket's settings, a KMS key of its own, and one IAM role per app that writes to it.Drift is put back. Every resource is compared with AWS every minute. A change made in the console is undone, not found months later by a
terraform plan.
| Project | Bucket | Used by |
|---|---|---|
app-backups |
obiwalker-app-backups |
Gitea and MinIO nightly backups (write-only) |
cnpg-backups |
obiwalker-cnpg-backups |
Both Postgres clusters' WAL and base backups |
What the per-project keys cost, and why WAL still ships every few minutes: FinOps: KMS keys for backups.
Why this choice
Long-lived shared infrastructure should be reconciled, not applied. In
an enterprise with hundreds of AWS accounts, the VPC, the backup buckets and
the roles around them usually live in a Terraform module, applied once per
account. Each account then has its own state file and its own pinned module
version. Drift is found only when someone runs plan, and changing every
account means hundreds of pipeline runs. A controller that holds the desired
state fixes both: change the definition once, and every copy converges and
stays converged.
Terraform still has a job: the trust anchors. Something has to exist before Crossplane can act: the cluster's identity provider in AWS, the roles Crossplane uses, and the guardrails on them. In an enterprise, that's account vending. Here, it's a small script, reviewed and run by an admin. Crossplane owns everything inside a project.
A platform API, not raw AWS objects. A project asks for a
BackupStore with a few fields: its writers, their prefixes, write-only or
read-write, and how long to keep objects. Which encryption, which bucket
policy and which role guardrails is decided once, by the platform, in the
Composition. That's also why it's Crossplane and not AWS Controllers for
Kubernetes (ACK). ACK maps AWS APIs one to one, so it has no layer to
decide this in.
Three identities, three jobs
provider pod token ─▶ AWS STS ─▶ deployer role for builds, never reads
├─ bucket obiwalker-: settings, policy, encryption
├─ KMS key tagged project=
└─ app roles on /workload//, capped by the boundary
backup pod token ──▶ AWS STS ─▶ app role reads/writes, never builds
Setup script (trust anchors): creates one deployer role per project, the deployer policy, and the permissions boundary.
Deployer role (Crossplane): one per project, named by that project's ProviderConfig. It can build the vault, but never open it.
App role (the workload): one per writer. It can use its own prefix of the bucket, and nothing else. It can't create or change anything.
No AWS access key exists anywhere in this chain. Every pod, Crossplane's included, trades its own short-lived Kubernetes token with AWS STS (Identity).
Least privilege, enforced by AWS
One deployer policy serves every project. It scopes itself with the role's
own project tag, so adding a project adds no policy to review.
Bucket settings only, never objects. A deployer can create and configure its own bucket. It can't read, write or delete a single object.
Key administration only, never use. It can create and manage keys tagged with its own project. It can't encrypt, decrypt or grant either.
Roles only with the boundary. It can create roles only on its own path, and only with the permissions boundary attached. It can't remove a boundary. Without this, a role that can create roles can create an admin.
No tagging for another project. Tags decide who may use a key, so a deployer can't create or tag anything with another project's tag, or remove a
projecttag.Nothing destroyed. No deployer can delete a bucket, schedule a key's deletion, or disable a key. Crossplane is also told never to delete them.
The key policy is the second lock, on the resource itself. Every
statement names who it's for, so writing an IAM policy (even kms:*)
grants nothing on a project key:
| Who | Can |
|---|---|
| A break-glass admin, signed in with a passkey | everything |
| The SSO admin role | administer the key; decrypt through S3 to restore |
| The project's deployer (Crossplane) | maintain the key and change its policy |
| The project's app roles, from this account, through S3 | encrypt and decrypt |
The caller's own IAM policy must allow it too. Nobody else can create a grant, which would hand out decryption without these conditions. If a role's trust were ever pointed at an outside account, that account still couldn't decrypt.
Write-only means write-only. Gitea's and MinIO's backup roles can upload
but can't list, read or delete backups, so anything that takes over the
cluster can't wipe them. They still need kms:Decrypt as well as
GenerateDataKey, because large uploads come in parts and S3 needs both to
put the parts together. Without s3:GetObject, they still can't read.
What this proves
TODO: results, after the checks have run: drift put back, each deployer guardrail denied, a cross-project key use denied, and the backups and restore drills passing on the new roles and keys.
| Check | Expected | Result |
|---|---|---|
| Lifecycle rule deleted in the console | restored within a minute | |
| Deployer reads a backup | AccessDenied |
|
| Deployer uses its own key | AccessDenied |
|
| Deployer creates a role tagged for another project | AccessDenied |
|
| Deployer creates a role without the boundary | AccessDenied |
|
cnpg-backups role uses the app-backups key |
AccessDenied |
|
A role with kms:* in IAM changes a key policy or decrypts |
AccessDenied |
✅ 2026-10-22: denied on describe, read policy, change policy, grant, use and schedule deletion |
| Anyone but break glass creates a grant on a key | AccessDenied |
✅ 2026-10-22: denied for the SSO admin and the deployer |
| Gitea, MinIO and Postgres restore drills | match the live data |
Moving existing buckets over
The buckets already hold backups, so Crossplane adopts them in three steps:
Observe first. Crossplane only watches the existing bucket and its settings, and shows what it would change.
New roles. The writers move to app roles that Crossplane creates. An IAM role's path can't change, so these are new roles, not adopted ones.
Manage. Crossplane takes over the settings. New objects are encrypted with the project's own key. Old objects expire in 7 days, or leave Postgres's 14-day window, so nothing needs re-encrypting.
Trade-offs and known limits
The cluster becomes part of the control plane. If Crossplane is down, nothing is corrected, though nothing breaks either: AWS keeps running what exists.
One bad Composition reaches every project at once. Two projects make that fine here. At enterprise scale, Composition revisions roll a change out to a few projects first. That's the next demo, with VPCs across accounts (Roadmap).
An untagged KMS key could be claimed. A deployer may tag a key at creation, and AWS can't tell that apart from tagging an existing, untagged key. Every key in the account carries a
projecttag.Crossplane can change who may decrypt. The deployer keeps the right to change its key's policy, so key-policy changes stay in git like everything else. AWS can't limit what a policy says, so the control is git review: Argo CD applies only what's merged. The alternative, making every key-policy change a manual step, was weighed and rejected for now.
A deployer can rewrite its bucket policy for the same reason. Another account given access would get ciphertext only, because the key refuses other accounts. The org-level fix is an AWS Organizations resource control policy, with the second account.
A deployer can list its bucket's object names, because Crossplane needs
ListBucketto check that the bucket exists. It still can't read them.One account for now. A second account needs either the cluster's identity registered there too, or a hub role in between.