RBAC, policy & guardrails
Least privilege on the control plane.
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.
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.
# The three org-wide roles the RBAC manager keeps in sync.$ kubectl get clusterrole crossplane-admin crossplane-edit crossplane-view
NAME CREATED ATcrossplane-admin 2026-07-18T09:12:44Zcrossplane-edit 2026-07-18T09:12:44Zcrossplane-view 2026-07-18T09:12:44Z
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.
$ 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 ; doprintf '%-58s' "$r"kubectl auth can-i create "$r" --as="$SA" -n team-ordersdone
postgresinstances.acme.io yescompositions.apiextensions.crossplane.io nocompositeresourcedefinitions.apiextensions.crossplane.io noproviders.pkg.crossplane.io noproviderconfigs.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.
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:namespace: team-ordersname: claim-authorrules:# 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/v1kind: RoleBindingmetadata:namespace: team-ordersname: claim-authorsubjects:- kind: Groupname: team-orders-devsapiGroup: rbac.authorization.k8s.ioroleRef:kind: Rolename: claim-authorapiGroup: 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.
# Who can create Compositions right now? The answer should be a tiny list.$ kubectl who-can create compositions.apiextensions.crossplane.io
No subjects found with permissions to create compositions assigned through RoleBindingsCLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACEcrossplane-admins platform-admins Groupcluster-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.
apiVersion: aws.upbound.io/v1beta1kind: ProviderConfigmetadata:name: aws-prodspec:credentials:source: WebIdentitywebIdentity:# 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.
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.
apiVersion: kyverno.io/v1kind: ClusterPolicymetadata:name: claims-allowed-regionsspec:rules:- name: region-allowlistmatch:any:- resources:kinds: ["PostgresInstance"]validate:failureAction: Enforcemessage: "spec.region must be one of eu-west-1, eu-central-1."pattern:spec:region: "eu-west-1 | eu-central-1"
$ kubectl apply -f claim-us-east.yaml
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 policiesclaims-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.
source: WebIdentity and away from source: Secret. What is the security reason?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.