CoursesCrossplaneXRDs & self-service claims

XRDs & self-service claims

Publish a platform API developers claim.

Advanced14 min · lesson 6 of 12

A good coffee shop hands you a menu, not the keys to the espresso machine. You point at "large oat latte," and someone who knows the machine makes it the right way every time. You never touch the grinder, the steam wand, or the beans. In Crossplane, the menu is a Composite Resource Definition (XRD), the printed list of things people are allowed to order and exactly how each order must be written down. The order slip a developer fills in is called a claim. The machine behind the counter, the composite resource and its composition, is where the real work happens, safely out of the customer's reach.

An XRD (Composite Resource Definition) is the schema for one of your platform's own APIs. An API (Application Programming Interface) is the fixed set of requests a system agrees to accept, the menu again: "large oat latte" is on it, "let me behind the counter" is not. The XRD says what the type is called, which fields it has, what values those fields accept, and which of them are required. Apply an XRD and Crossplane does two things at once. It creates the composite type, the XR (Composite Resource), which is the cluster-wide object your platform team owns and manages (a cluster is the whole pool of machines Kubernetes runs as one system). And, if you ask for it, it creates a claim type: a namespaced, stripped-down version that developers create inside their own namespace (a namespace is a walled-off section of that cluster belonging to one team, like a team's own labeled shelf). The claim is the front door. The XR and the composition are the plumbing behind the wall.

Publishing the API

Here is the whole menu, written as one file. Read it top to bottom the way Crossplane does. The group and names describe the cluster-scoped XR. The claimNames block is what turns on the namespaced claim; leave it out and you get an XR with no developer-facing front door at all. Each entry under versions carries the actual schema, written in OpenAPI (a standard, structured way to describe the shape and allowed values of an API; it is the same schema language Kubernetes publishes for its own built-in objects like a Pod or a Deployment).

xrd.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
# the name MUST be <plural>.<group>, or the apply is rejected
name: xpostgresinstances.acme.io
spec:
group: acme.io
names:
kind: XPostgresInstance # the cluster-scoped composite (platform owns it)
plural: xpostgresinstances
claimNames:
kind: PostgresInstance # the namespaced claim (developers create this)
plural: postgresinstances
defaultCompositionRef:
name: postgres-aws # which composition fulfills the claim by default
versions:
- name: v1alpha1
served: true # the API server serves this version
referenceable: true # compositions are allowed to target this version
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
description: "Instance size. One of: small, large."
enum: [small, large] # the only vocabulary a developer gets
required:
- size

Two flags on the version matter. served: true means the Kubernetes API server will accept and hand back objects of this version. referenceable: true marks the version that compositions may point at, and exactly one version must carry it. Notice how little schema you actually write. You define size and nothing else, yet the finished claim will also accept fields like compositionRef and writeConnectionSecretToRef. Crossplane injects those standard fields for you, so you only ever spell out the knobs that are specific to your API.

terminal
kubectl apply -f xrd.yaml
kubectl get xrd
output
compositeresourcedefinition.apiextensions.crossplane.io/xpostgresinstances.acme.io created
NAME ESTABLISHED OFFERED AGE
xpostgresinstances.acme.io True True 12s

Those two columns are your confirmation. ESTABLISHED goes True when the composite (XR) type is live in the API. OFFERED goes True when the claim type is live too, which only happens because you set claimNames. Behind that one apply, a single XRD has minted two Custom Resource Definitions (CRDs, the objects that register a new kind with Kubernetes): the cluster-scoped one and the namespaced one. You can list them the same way you would list any other resource type.

terminal
kubectl get crd | grep acme.io
output
postgresinstances.acme.io 2026-07-21T10:14:03Z
xpostgresinstances.acme.io 2026-07-21T10:14:03Z

The self-service front door

Now the developer orders. Their entire interaction with your platform is this one small file, dropped into their own namespace. No cloud console, no access keys, no thousand-line module they have to understand. They pick a size, name a Secret (Kubernetes' built-in object for holding a credential such as a password or connection string) to receive the connection details, and apply.

claim.yaml
# what a developer writes, in their own namespace
apiVersion: acme.io/v1alpha1
kind: PostgresInstance
metadata:
name: orders-db
namespace: team-orders
spec:
size: small
writeConnectionSecretToRef:
name: orders-db-conn # a Secret Crossplane creates here, in team-orders
terminal
kubectl apply -f claim.yaml
kubectl get postgresinstance -n team-orders
output
postgresinstance.acme.io/orders-db created
NAME SYNCED READY CONNECTION-SECRET AGE
orders-db True True orders-db-conn 3m12s

Two columns tell you where the order stands. SYNCED=True means Crossplane accepted your claim and reconciled it. READY=True means the database underneath actually exists and is usable. In the first minutes you will often see SYNCED=True with READY=False, and that is normal: the request was valid, the cloud is still building. Gate an application's rollout on READY, never on SYNCED alone.

That tiny file kicked off a lot. Crossplane created a matching XR one level up (cluster-scoped, named after the claim with a short random suffix), picked the composition, and the composition built the real database: the instance, its network, encryption at rest, backups, every setting you decided the developer should never have to think about. The claim and its XR keep pointers to each other, the claim's spec.resourceRef names its XR and the XR's spec.claimRef names the claim, which is how a namespaced order and a cluster-scoped machine stay tied together. When the database reports ready, the connection details land in the orders-db-conn Secret, right there in team-orders, for an app to mount.

terminal
# the cluster-scoped XR that the claim created (developers never touch this)
kubectl get xpostgresinstance
# the connection Secret, sitting in the developer's own namespace
kubectl get secret orders-db-conn -n team-orders
output
NAME SYNCED READY COMPOSITION AGE
orders-db-7q2xk True True postgres-aws 3m20s
NAME TYPE DATA AGE
orders-db-conn connection.crossplane.io/v1alpha1 4 3m20s

The schema is your policy surface

Here is the part that turns an XRD from a convenience into a security control. Every field you put on the claim is a field a developer can set to anything the schema allows. That cuts both ways. Expose a tidy size: small or large, and the worst a careless developer can do is ask for the wrong size. Expose a raw publiclyAccessible boolean or a free-text cidrBlock, and the same careless developer, or an attacker who has landed a foothold in that namespace, can open your database to the whole internet with one line of YAML (the plain-text format Kubernetes objects are written in) that passes validation cleanly. The XRD schema is the fence. A loose schema is a fence with the gate left open.

Do not surface dangerous knobs on a claim
Whatever you expose, developers own. Never put raw network reachability, arbitrary instance classes, region, or IAM (Identity and Access Management, the cloud's own permission system) settings on a self-service claim, because a schema that validates is not the same thing as a request that is safe to grant. Keep the vocabulary small and boring (a size enum, a tier, a retention window bounded by a minimum and maximum), and let the composition make every risky decision correctly and identically for everyone. If a field could let one namespace hurt another, or expose data to the internet, it belongs in the composition, not the claim.

You tighten the fence with ordinary OpenAPI validation plus Kubernetes' own admission checks (extra rules the API server runs the instant an object is submitted, before it is written to storage). enum limits a field to a fixed list. pattern forces a string to match a regular expression. minimum and maximum bound a number. And x-kubernetes-validations lets you write a CEL rule (Common Expression Language, a small expression language the API server evaluates at that same admission moment) for things a plain schema cannot say, such as "this field can never change once it is set." Every one of these runs before the object is stored, so a bad claim is rejected the moment you run kubectl apply (kubectl is the Kubernetes command-line tool you have seen in every terminal block above), not discovered hours later in a cloud console.

xrd-guardrails.yaml
# under spec.properties in the XRD schema: tighten every field you expose
size:
type: string
enum: [small, large]
storageGB:
type: integer
minimum: 20 # no absurdly small or oversized volumes
maximum: 500
region:
type: string
enum: [eu-west-1, eu-central-1] # only regions you actually operate in
x-kubernetes-validations:
- rule: "self == oldSelf" # region is immutable once it is set
message: "region cannot be changed after creation"

Before you publish, read your own API the way a developer will, using kubectl explain. It prints exactly which fields the claim accepts, which are required, and the descriptions you wrote into the schema. This is your last check that you are not handing out a knob you meant to keep to yourself.

terminal
kubectl explain postgresinstance.spec
output
GROUP: acme.io
KIND: PostgresInstance
VERSION: v1alpha1
FIELD: spec <Object>
DESCRIPTION:
<no description>
FIELDS:
compositionRef <Object>
compositionRevisionRef <Object>
compositionRevisionSelector <Object>
compositionSelector <Object>
compositionUpdatePolicy <string>
publishConnectionDetailsTo <Object>
resourceRef <Object>
size <string> -required-
Instance size. One of: small, large.
writeConnectionSecretToRef <Object>

The fields you did not write, compositionRef, resourceRef, and the rest, are Crossplane's standard plumbing, injected into every claim. size is the only line of vocabulary you invented, and it is marked -required-, exactly as the schema promised. If a field you meant to hide ever shows up in this list, it is reachable, and you fix the XRD before a single claim is filed against it.

Who is allowed to order

Schema decides what can be asked for. Who is allowed to ask is a separate question, and RBAC (Role-Based Access Control, Kubernetes' permission system) answers it, working like the badge reader on each door: it does not care what you want, only whether your badge opens this one. Schema and RBAC are two different fences, and you need both. The design that makes self-service safe is narrow: developers get permission to create the namespaced claim type inside their own namespace, and nothing else. They cannot touch the cluster-scoped XR, the composition, the XRD, or the managed resources out in the cloud. Give a team exactly the claim, in exactly their namespace, and the blast radius of a stolen developer token stops at the edge of that namespace.

dev-access.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: postgres-claimant
namespace: team-orders # scoped to one namespace only
rules:
- apiGroups: ["acme.io"]
resources: ["postgresinstances"] # the claim type, not the XR
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: postgres-claimant
namespace: team-orders
subjects:
- kind: Group
name: team-orders-devs
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: postgres-claimant
apiGroup: rbac.authorization.k8s.io
Offering a claim can grant access you never wrote
Crossplane's RBAC manager automatically creates ClusterRoles for every claim type and aggregates them into Kubernetes' built-in admin, edit, and view roles. So the moment your XRD's OFFERED column goes True, anyone who already holds edit or admin in a namespace can create your new claim there, with no RoleBinding from you. That is convenient on a platform where edit already means "trusted developer," and a nasty surprise on a shared cluster where edit was handed out freely. After you publish an XRD, check who actually holds those built-in roles with: kubectl get rolebindings,clusterrolebindings -A -o wide
WHO CONTROLS WHAT
Developer (their namespace)
PostgresInstance claim
size: small, one small file
orders-db-conn Secret
connection details land here
RBAC: claim only
cannot see the XR or the cloud
Platform team (cluster-scoped)
XRD
the API and schema = the guardrail
XR: XPostgresInstance
platform-managed composite
Composition
makes the risky choices correctly
Cloud account
Managed resources
database instance, subnet group, encryption key
Encrypted and backed up
decided by the composition
No direct access
developers never reach here
Developers hold RBAC on the claim only. Everything to its right is platform-owned, and the XRD schema is the fence between the columns.

If you are on Crossplane v2

Crossplane v2, released in 2025, reshapes this model. Composite resources can now live inside a namespace, and claims become a compatibility feature rather than the default path. The XRD gains a scope field (with values like Namespaced, Cluster, and LegacyCluster) that picks which shape you get. On a v2 platform you often publish a namespaced XR and let developers create that directly, with no separate claim type at all. What you just learned does not change: a small, validated API in the developer's own namespace, with the messy machinery hidden behind it and RBAC scoped tight. Claims still work, plenty of running platforms depend on them, and almost every XRD you meet in the field will be one shape or the other, so both are worth understanding.

Quick check
01You apply an XRD with claimNames set, and its OFFERED column reads True. Several teams already have the built-in edit ClusterRole bound in their namespaces, but you have written no RoleBinding for the new claim. Who can create it?
Incorrect — Crossplane's RBAC manager aggregates claim permissions into the built-in roles for you, automatically.
Correct — Offering a claim silently widens what your existing edit and admin users can already do.
Incorrect — The claim is namespaced, so the edit role bound inside a namespace already reaches it; cluster-admin is not required.
Incorrect — Claims are normal namespaced resources fully governed by RBAC; the surprise is aggregation, not a bypass.
02An XRD's versions list can hold more than one entry. Setting referenceable: true on exactly one of them controls what?
Incorrect — that is the job of served: true; referenceable is a separate flag.
Correct — referenceable: true marks the composition-targetable version, and the lesson notes exactly one version must have it.
Incorrect — there is no such defaulting; defaultCompositionRef picks a composition, not an API version.
Incorrect — claimNames turns the claim on, independently of referenceable.
03Minutes after a developer applies a PostgresInstance claim, kubectl get postgresinstance shows SYNCED=True but READY=False, and an app is waiting on the database. What is the correct read, and what should gate the app's rollout?
Incorrect — SYNCED=True means Crossplane accepted and reconciled the claim, not that it was rejected.
Incorrect — READY=False means the underlying database does not yet exist or is not usable, so the app must wait.
Correct — the lesson calls this the normal early state and says to gate on READY, never on SYNCED alone.
Incorrect — SYNCED=True means the XR was created and reconciled; READY=False here is provisioning latency, not a broken reference.

Before an XRD reaches a real cluster, prove the fence holds. Apply it to a staging control plane (a throwaway, non-production Crossplane cluster you can afford to break), then test the boundary while impersonating a service account (the non-human identity a running app signs in as, rather than a person). Running kubectl auth can-i create postgresinstances -n team-orders --as=system:serviceaccount:team-orders:app should answer yes, and kubectl auth can-i get xpostgresinstances --as=system:serviceaccount:team-orders:app should answer no. If a low-privilege token can reach the composite, the XR, or the cloud beneath it, the leak is in your RBAC, and you fix it there, on staging, where the only thing at risk is a throwaway test database.

Try this

Run kubectl apply -f xrd.yaml 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: do not surface dangerous knobs on a claim. 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