The XRole Claim API: XRD, Composition, and the Function Pipeline
This is the day the platform becomes a product. Everything so far — controller, providers, boundary — exists so that an app team can write a few lines of YAML and get a per-service cloud identity with exactly the access it needs. This day designs that claim: the `XRole` API a team writes, the per-cluster facts the platform injects, the function pipeline that expands one claim into the right provider resources on either cloud, and the hardening that stops a claim from asking for too much.
What the team writes
The claim is intentionally tiny and high-level. It names the service, the Kubernetes ServiceAccount to bind for trust, an owner for attribution, and a set of named capability packages — never raw cloud permissions.
apiVersion: platform.example.com/v1alpha1
kind: XRole
metadata:
name: echo-server
namespace: echo-server
labels:
platform.example.com/owner: media-team
app.kubernetes.io/managed-by: crossplane
spec:
owner: media-team # required; stamped on every minted resource
serviceName: echo-server # base name for the minted identity
serviceAccountName: echo-server # the KSA bound in the trust policy
access: [standard, elevated] # capability packages (or specific profiles)
Four fields carry the whole contract: owner (attribution, mirrored to a label and stamped on every resource), serviceName (the base for the minted identity name), serviceAccountName (the exact KSA bound for trust — often not equal to the service name), and access (the only grant surface — package names, or specific profiles within them). Rule of thumb: a good claim expresses intent (this service, this owner, these capabilities) and nothing operational — no ARNs, no project IDs, no provider choice; if a field encodes an infrastructure fact, it doesn't belong in the claim.
No per-claim escape hatch
The most important design decision is a removal: there is no extraRoles or extraStatements field. access — a list of named, allowlisted profiles — is the sole way to request permission. Need a grant no profile covers? You add a new profile to the shared catalog in one reviewed pull request, then reference it. This keeps every grant an auditable, named, reviewed thing, and it keeps one consistent model across both clouds (where an AWS grant is a policy statement and a GCP grant is a role binding — a raw per-claim list couldn't represent both anyway).
Rule of thumb: a self-service permission API with an escape hatch is not an allowlist — close it, and route every genuinely new need through a reviewed addition to the shared catalog so the menu grows deliberately instead of each claim inventing its own access.
Infra facts live in an EnvironmentConfig, not the claim
The claim can't be allowed to specify which cluster, project, account, or boundary it targets — that would be a confused-deputy hole (a claim naming a different cluster's facts to escalate). Those per-cluster facts live in a git-authored, platform-owned EnvironmentConfig, one per cluster, read by the composition at render time and not settable from the claim.
| EnvironmentConfig key | Used for |
|---|---|
clusterID | Name prefix xp-app-<cluster>-… |
cloud | aws or gcp — the function asserts it matches its composition |
gcpProjectID / awsAccountID | Building SA paths / role ARNs |
awsBoundaryArn | The permissions-boundary ceiling on every AWS role |
| OIDC provider / issuer | Building the AWS trust policy |
providerConfigName | The pinned provider config reference |
Rule of thumb: split claim inputs from infrastructure facts — the app supplies what it is and wants, the platform supplies where and how, and the two never mix, so a claim can't reach into another cluster's context.
Per-KSA trust closes the lateral-assume hole
Because the composition mints the identity and already knows the claiming service's ServiceAccount, it can bind trust to the exact KSA rather than a namespace wildcard — system:serviceaccount:<ns>:<ksa> on AWS, the exact Workload Identity member on GCP. The exact subject costs nothing extra and closes the intra-namespace lateral-assume hole a wildcard leaves open (a neighbouring workload in the same namespace can't assume the role).
Rule of thumb: when the minting system knows the precise principal, bind to it exactly — a namespace-scoped trust is a lateral-movement gift you can decline for free.
The composition pipeline: from one claim to many resources
Two compositions exist — one per cloud, labelled and pinned per cluster so a claim on a GKE cluster can only ever hit the GCP composition (and vice versa). Because each is single-cloud, its logic has no per-cloud branching. The pipeline runs three functions:
The middle step does the real work: look up each requested profile in the catalog, collect and de-duplicate the roles, and fan out one grant resource per role (plus the fixed identity and trust resources), computing a deterministic, length-capped name. Two implementation choices are worth calling out. First, go-templating over a custom function image: the expand-and-fan-out logic (profile lookup, uniq, hash, truncate) fits inside standard templating, so there's no function image to build, version, and pin — a CI render check gives the same "nothing dropped" assurance without owning a supply chain. Second, fan-out identity by content hash, not array index: key each generated resource by a hash of (project/account, role, member) so reordering the role list doesn't churn or recreate grants.
Rule of thumb: prefer inline templating to a shipped function image when the logic fits, and key fanned-out resources by what they are (a content hash) not where they sit in a list — index-keyed resources thrash on every reorder.
Naming under real-world limits
Minted identities must be unique across clusters that share a project or account, and must fit hard length ceilings (30 characters for a GCP service-account ID, 64 for an AWS role name). The scheme is xp-app-<cluster>-<namespace>-<service>, deterministically truncated with a short hash suffix so it stays unique after truncation. Namespace is included so two services with the same serviceName in different namespaces don't collide.
Rule of thumb: design names for the shared case and the shortest ceiling from day one — a truncate-plus-hash scheme that includes the cluster and namespace is far cheaper to adopt now than to retrofit after collisions appear in a shared account.
Hardening summary
The claim is safe because of a stack of independent limits, each in a different layer: the XRD's CEL validation (access ⊆ known profiles, owner required, takeover roles rejected), the composition (only known profiles expand; the provider config and — on AWS — the permission boundary are hardcoded), and, on the cluster, an admission policy (Day 07). No single field the app controls can widen the grant beyond the allowlist. Rule of thumb: put the guardrails in the template every claim flows through, not in a human review of each claim — encode the policy once so safety is uniform and automatic, and let review focus on changes to the catalog itself.
Key takeaways
- The
XRoleclaim is high-level and tiny: owner, service name, the KSA to bind, and a list of named capability packages — no ARNs, projects, or provider choice. - No escape hatch:
access(allowlisted profiles) is the only grant surface; a new need means a reviewed addition to the shared catalog, not a raw permission in the claim. - Infra facts live in a platform-owned EnvironmentConfig, not the claim, closing the confused-deputy hole; the composition reads them at render time.
- Trust binds to the exact KSA, not a namespace wildcard, closing intra-namespace lateral assume for free.
- The per-cloud composition pipeline loads cluster facts, expands profiles and fans out one grant per role via inline go-templating (no shipped function image), and marks readiness — keying resources by content hash, not list index.
- Names include cluster and namespace and are truncate-plus-hash'd to the tightest cloud ceiling so they stay unique in shared projects/accounts.
Checklist
- [ ] I can write a minimal
XRoleclaim and explain what each of the four fields does. - [ ] I can explain why there is no per-claim escape hatch and how a genuinely new permission gets added.
- [ ] I can say why cluster/project/account facts live in an EnvironmentConfig instead of the claim, and what attack that prevents.
- [ ] I can explain why trust binds to the exact KSA and what a namespace wildcard would expose.
- [ ] I can describe the three-step composition pipeline and justify go-templating over a custom function image and content-hash keys over list indices.
- [ ] I can explain the naming scheme and why it must handle shared accounts and length ceilings from the start.