CoursesCrossplaneRBAC, policy & guardrails

RBAC, policy & guardrails

Least privilege on the control plane.

Advanced14 min · lesson 11 of 12

A locksmith's workshop is the most dangerous room in a building. It keeps a key to every door, and it can cut brand-new keys on request. Nobody sane hands out a copy of that room's key to every tenant who wants into their own office. Your Crossplane control plane is that workshop. It holds your cloud credentials, and it turns YAML files (plain-text config files) into real infrastructure around the clock, so the question of who is allowed to walk in and ask for what is the whole security story.

Two separate locks decide what actually happens. The first is Kubernetes RBAC (Role-Based Access Control, the cluster's rules for who may do what to which objects). It decides who can file a claim, who can write a Composition, who can install a provider. The second is cloud IAM (Identity and Access Management, your cloud account's own permission system). The role your ProviderConfig assumes sets a hard ceiling on what Crossplane can do out in the account, no matter what the YAML asks for. Get one lock right and the other wrong and you are still wide open.

The two roles you are separating

A developer should be able to file a claim in their namespace, a small request that says "I need a Postgres database," and never be able to install a provider or edit a Composition. Those are two different jobs. In Crossplane terms, the developer side is claims (namespaced, developer-facing requests). The platform side is everything that defines what a claim becomes: the XRD (Composite Resource Definition, which invents a new API), the Composition (the template that turns one claim into a pile of real cloud resources), the Provider (the package that installs a cloud's controller and its resource types), and the ProviderConfig (which tells that provider whose credentials to use). The first is routine. The second is admin over your infrastructure.

Who should hold which verb
Developer (per namespace)
claims
create / delete in own ns
own claim status
read only, to see progress
nothing cluster-scoped
no Compositions, no packages
Platform team (cluster-wide, privileged)
Compositions and XRDs
define what a claim builds
Providers and Functions
code that runs by your creds
ProviderConfigs
choose the cloud role
Cloud IAM (the real ceiling)
scoped role per environment
least privilege
short-lived credentials
IRSA / WebIdentity
separate accounts
a dev claim cannot reach prod
Everything in the middle zone is cloud admin, so guard it like root. The cloud role on the right is the ceiling on all of it.

What the RBAC manager already set up

Crossplane ships a helper for exactly this, the RBAC manager, which runs as its own pod named crossplane-rbac-manager. When it is running it maintains three cluster-wide roles you can bind people to: crossplane-admin, crossplane-edit, and crossplane-view. The detail worth reading rather than assuming: crossplane-edit deliberately leaves out Compositions, XRDs, and providers. It lets a person manage claims and composites, not the machinery that defines them. That split is the least-privilege model handed to you for free, if you actually bind people to it.

terminal
# The three org-wide roles the RBAC manager keeps in sync.
$ kubectl get clusterrole crossplane-admin crossplane-edit crossplane-view
output
NAME CREATED AT
crossplane-admin 2026-07-18T09:12:44Z
crossplane-edit 2026-07-18T09:12:44Z
crossplane-view 2026-07-18T09:12:44Z
Namespace edit already includes claims
The RBAC manager aggregates every XRD's claim permissions into Kubernetes' built-in admin and edit roles. So a teammate you granted namespace edit last month can create claims for an XRD you defined today, with no new grant and no review. That is the intended developer experience, but it surprises people during audits. If you need tighter control, you can stop the RBAC manager from managing these roles (for example by not deploying it, Helm value rbacManager.deploy=false) and define the roles by hand instead.

Prove least privilege with can-i, not with vibes

The fastest way to check an RBAC change is to ask the API server (the cluster's control desk that every request passes through) directly, while pretending to be the identity you care about. kubectl auth can-i answers yes or no for one action, and --as impersonates a user or service account. Run it before and after you apply a Role and you have a test, not a hope. Here is the whole developer surface in one pass, checking a namespace service account against the objects that matter.

terminal
$ SA=system:serviceaccount:team-orders:default
$ for r in \
postgresinstances.acme.io \
compositions.apiextensions.crossplane.io \
compositeresourcedefinitions.apiextensions.crossplane.io \
providers.pkg.crossplane.io \
providerconfigs.aws.upbound.io ; do
printf '%-58s' "$r"
kubectl auth can-i create "$r" --as="$SA" -n team-orders
done
output
postgresinstances.acme.io yes
compositions.apiextensions.crossplane.io no
compositeresourcedefinitions.apiextensions.crossplane.io no
providers.pkg.crossplane.io no
providerconfigs.aws.upbound.io no

Read that output like a defender. The developer service account can create the one claim type it owns and nothing else. Compositions, XRDs, providers, and ProviderConfigs all say no. Those four no's are the guardrail. The Role that produces them is small and boring on purpose, scoped to the claim's own API group and namespace.

claim-author.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: team-orders
name: claim-author
rules:
# Claims only. Not composites (XRs), not Compositions, not XRDs.
- apiGroups: ["acme.io"]
resources: ["postgresinstances"]
verbs: ["get", "list", "watch", "create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: team-orders
name: claim-author
subjects:
- kind: Group
name: team-orders-devs
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: claim-author
apiGroup: rbac.authorization.k8s.io

The escalation an attacker is hoping for

Here is why the platform-owned objects are not a routine permission. A Composition is a template, and it can reference any provider and set any field on any managed resource. Whoever can write one can make Crossplane build an IAM role that trusts them, an S3 bucket (Amazon's object storage) open to the internet, or a network peering into infrastructure they control, right up to whatever the ProviderConfig's cloud role allows. Writing a Composition is not like editing a config file. It is closer to holding the cloud account's admin credentials.

So the attacker's real target, once they phish a developer's kubeconfig, is not the claim. It is finding that the same identity can also create or edit a Composition or a ProviderConfig, or install a provider or Function package. Any one of those turns a namespace foothold into cloud-account control. And a provider or Function is code that runs inside your control plane next to its credentials, so installing one is running an untrusted binary in the most sensitive room you have. A defender's move is the reverse lookup: given the dangerous verb, who currently holds it? Kubernetes has no built-in reverse query, but the kubectl-who-can plugin (install with kubectl krew install who-can) answers it.

terminal
# Who can create Compositions right now? The answer should be a tiny list.
$ kubectl who-can create compositions.apiextensions.crossplane.io
output
No subjects found with permissions to create compositions assigned through RoleBindings
CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE
crossplane-admins platform-admins Group
cluster-admin system:masters Group

The cloud role is the ceiling on everything

Even flawless RBAC does not save you if the ProviderConfig assumes a cloud role that can do anything. RBAC decides who is allowed to ask. The cloud role decides how much damage any request can do once Crossplane acts on it. Scope that role down, one per environment, least privilege, and a mistaken or malicious Composition cannot exceed it no matter who wrote it.

Prefer short-lived, assumed credentials over static keys. On AWS that means IRSA (IAM Roles for Service Accounts) or WebIdentity: the provider's pod gets a temporary token tied to an IAM role, handed over through the cluster's OIDC provider (OpenID Connect, a standard way for the cloud to trust tokens the cluster issues). No long-lived access key sits in a Secret waiting to be stolen.

providerconfig-prod.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: aws-prod
spec:
credentials:
source: WebIdentity
webIdentity:
# A tightly scoped IAM role. THIS is the real blast radius.
roleARN: arn:aws:iam::111122223333:role/crossplane-prod-rds-only

Run the provider under its own ServiceAccount using a DeploymentRuntimeConfig (the object that shapes how a provider's pod runs; it replaced the older ControllerConfig), and wire that ServiceAccount up for IRSA so the pod can fetch its short-lived token. Give the dev environment its own ProviderConfig pointing at a separate, weaker role in a separate account, and a claim filed in team-orders has no path into prod even if the RBAC around it ever slips.

source: Secret is a long-lived key in your database
If you set credentials source to Secret, that Secret holds a static cloud key living in etcd (the cluster's database). Anyone who can read Secrets in that namespace, snapshot etcd, or exec into the provider pod now owns your cloud account, and the credential does not expire on its own. Treat source: Secret as a last resort. When you must use it, lock down who can read that Secret and rotate it on a schedule.

Guardrails RBAC cannot express

RBAC is a yes or no on an object type. It cannot say "you may create a database, but not a public one." That finer check is the job of an admission policy engine like Kyverno or OPA Gatekeeper (OPA is Open Policy Agent). Think of RBAC as the guard who checks whether your badge opens the door, and the policy engine as the guard who then reads what you are actually carrying through it. The engine sits in front of the API server and inspects the object itself before Crossplane ever sees it, so you can require labels, restrict regions, and forbid public networking on the claim at the door. This policy rejects any Postgres claim asking for a region outside your allowlist.

require-region.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: claims-allowed-regions
spec:
rules:
- name: region-allowlist
match:
any:
- resources:
kinds: ["PostgresInstance"]
validate:
failureAction: Enforce
message: "spec.region must be one of eu-west-1, eu-central-1."
pattern:
spec:
region: "eu-west-1 | eu-central-1"
terminal
$ kubectl apply -f claim-us-east.yaml
output
Error from server: error when creating "claim-us-east.yaml": admission webhook
"validate.kyverno.svc-fail" denied the request:
resource PostgresInstance/team-orders/orders-db was blocked due to the following policies
claims-allowed-regions:
region-allowlist: 'validation error: spec.region must be one of eu-west-1,
eu-central-1. rule region-allowlist failed at path /spec/region/'

The denial is also your detection signal. Kyverno emits a Kubernetes event and bumps a Prometheus counter every time it blocks something, so a spike of denials from one namespace is a person or a pipeline probing the edges of what you allow. Feed those into alerting the same way you would a burst of failed sudo attempts.

Quick check
01An auditor asks why "can create Compositions" is treated as a cloud-admin permission rather than a routine developer one. What is the best answer?
Correct — Composition authoring is effectively cloud admin, bounded only by the cloud role.
Incorrect — Being cluster-scoped is not what makes it dangerous, and there is no such blanket default.
Incorrect — Editing a Composition does not restart Crossplane; the danger is what it can provision.
Incorrect — Credentials live in the ProviderConfig and its Secret, never in the Composition.
02For a production ProviderConfig, the lesson steers you toward source: WebIdentity and away from source: Secret. What is the security reason?
Incorrect — WebIdentity still assumes a scoped IAM role through its roleARN, and the lesson calls that role the real blast radius you must tighten, not something it removes.
Incorrect — the lesson treats Secret as a last resort to lock down and rotate, not a removed or non-functional option.
Correct — the danger is a permanent, stealable key at rest, and WebIdentity replaces it with an expiring token that nobody can lift from a Secret.
Incorrect — the point is that no static key exists at all with WebIdentity, not that an existing key is encrypted.
03Developers can already file Postgres claims in their own namespace. Security now wants a hard guarantee that nobody can create a publicly-reachable database, while a private one from the very same claim type must still be allowed. The claim has a field that toggles public access. Which layer enforces this correctly?
Incorrect — RBAC only grants or denies a verb on an object type; it cannot read a field's value inside the claim, so there is no 'public' verb to take away.
Correct — 'you may create a database, but not a public one' is field-level validation, exactly the gap an admission policy engine is built to fill, and it inspects the object before Crossplane ever acts on it.
Incorrect — the cloud role is a blunt ceiling on everything Crossplane builds and cannot deliver a per-claim, field-level allow/deny at submit time, which is what this requirement needs.
Incorrect — that removes every database, private ones included, so it breaks the requirement to keep allowing private databases.

Keep it honest over time

All of this rots if nobody watches it. Three habits keep it real. Gate every change to Compositions, XRDs, providers, Functions, and ProviderConfigs through GitOps review (pull requests, not kubectl apply from a laptop), so a second person signs off on anything that touches the workshop. Turn on API server audit logging and alert on writes to compositions, compositeresourcedefinitions, and pkg.crossplane.io resources, which are rare and always worth a look. And re-run your kubectl auth can-i checks in continuous integration, so a Role that quietly gains a verb fails a build instead of a pentest.

The no from kubectl auth can-i create providers.pkg.crossplane.io --as=<dev-sa> is the single check most worth wiring into your pipeline. The day it turns into yes, someone has quietly promoted a developer into an infrastructure administrator, and you want a red build to tell you before an attacker finds it first.

Try this

Run kubectl get clusterrole crossplane-admin crossplane-edit crossplane-view on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: namespace edit already includes claims. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related