ADR-053: Identity & Cross-System Authorization Strategy
Date: 2026-06-04
Status: Accepted — largely delivered (updated 2026-06-09). Keycloak is the live app-facing IdP; ArgoCD +
Backstage sign in via Keycloak OIDC; Dex + oauth2-proxy retired; Backstage group-based RBAC shipped (#197).
What remains is the access-model-as-code generators reading the git-native Team
(ADR-063), landing with the rebuild cutover
(ADR-049). Complements the AWS+Kubernetes RBAC of
ADR-039/ADR-040/ADR-041/ADR-047;
charts the eventual replacement of the Dex broker (ADR-052). Unblocks the
deferred Backstage RBAC (#197). Refined by ADR-059 — the
“Identity Center is the user store, Keycloak brokers up” framing is one instance of a topology where the upstream
IdP (Okta / Entra / Google / IdC / standalone) is pluggable behind the Keycloak seam. Extended by
ADR-068 — once ADR-067 made
Product first-class, this strategy’s team-scoped taxonomy could no longer express product-scoped or
cross-team access; ADR-068 specifies the concrete model (the AccessGrant object, access:<team>/<product>
roles, the release-approver/team-admin roles, and OIDC-native cluster auth) that the generators realize.
Amendment — 2026-06-08: Keycloak is the IdP of record by default
Section titled “Amendment — 2026-06-08: Keycloak is the IdP of record by default”The original framing made AWS Identity Center the user store and Keycloak a broker up to it (ADR-059 scenario C). Building the Team→group/role taxonomy surfaced that this is the wrong default: IdC can’t emit group claims, so the access model sat inert behind a broker that couldn’t feed it — and hard-requiring an AWS Organization with Identity Center makes a reference platform un-forkable. We are therefore flipping the default:
- Default = Keycloak is the IdP of record. Identity, membership, and the group→role taxonomy live in the
platformrealm (users seeded as code; see thekeycloak-configmodule’susersinput). The access model is live with no upstream — this is ADR-059 scenario B (“Keycloak-as-hub”), promoted from “a bootstrap” to the realized default.upstream = nullinkeycloak-config. - Federation is an opt-in layer, not the foundation. Set
keycloak-config’supstreamto broker to a corporate IdP (Okta / Entra / Google over OIDC, or AWS IdC over SAML) when an environment has one. Nothing downstream changes — apps, OIDC claims, the access-as-code generators, and AWS access are all invariant (the whole point of the ADR-052/059 vendor-neutral seam). With a group-emitting upstream the membership gap closes automatically (the IdP-group→Keycloak-group mapper); with IdC it stays manual. - Unchanged: everything else in this ADR. Keycloak as the app-facing OIDC IdP, access-as-code from
the git-native
Teamregistry (gitops/teams/, ADR-063 — formerly_teams.hcl), native per-system enforcement, and AWS account access via Identity Center permission sets (orthogonal to the app IdP — IdC stays the AWS-access SoR regardless of the upstream choice).
Net: this is a default flip within the ADR-059 pluggable seam, not a new direction — it makes the realized build match scenario B and treats IdC-as-app-IdP as one optional federation mode among several.
Context
Section titled “Context”Many systems on the platform need authorization, and the list only grows: AWS (accounts/IAM), ArgoCD, Kubernetes, Backstage, and future tools (Grafana, Crossplane/Kyverno views, etc.). Today there is one identity backbone — AWS Identity Center — but N fragmented enforcement points, each speaking its own authz dialect, and the mapping between them is hand-maintained per system.
What already works well (and stays): the AWS + Kubernetes access chain is coherent, role-based,
group-mapped, team-scoped, and default-deny —
SSO group → Dev-<team> permission set → DeveloperAccess-<team> IAM role → EKS access entry → team-<team>:developers → namespace RoleBinding
(ADR-039/040/041/047), generated by the Crossplane Tenant Composition. This ADR does not disturb it.
What is limiting — and it traces to Identity Center as the app IdP:
- It can’t cleanly emit group claims to OIDC apps. Backstage/Dex receives no groups over SAML (ADR-052:
“it does not emit group memberships as a SAML attribute … it fails the assertion”). ArgoCD only gets them via a
manual console attribute mapping, and then as opaque UUIDs (
g, a4b884e8-…, role:admin) — illegible and not IaC-managed. - Per-app SAML signing certs + console-only attribute mappings are the one slice of this area that can’t be managed as code.
- It is AWS-only. The platform’s north star is multi-cloud, compliance-aware, with a coherent cross-system
RBAC story; an AWS-proprietary identity provider is structurally misaligned with that. ADR-051/052 already saw
this and made the Dex issuer (
https://sso.aws.refplat.org) deliberately vendor-neutral as the swap seam. - The group→role mapping is duplicated and hardcoded per system (ArgoCD UUIDs, k8s group strings, AWS
permission sets, soon Backstage), and the scoping is inconsistent — k8s and AWS are team-scoped, but
ArgoCD’s
developerrole is global (*/*).
The goal (stated): a declarative, uniform way to define authorization and propagate it across the platform, optimizing for compatibility with external / off-the-shelf systems.
Decision
Section titled “Decision”Three linked decisions.
1. Enforcement model — declarative source → standard claims + generated native config (NOT a central runtime PDP)
Section titled “1. Enforcement model — declarative source → standard claims + generated native config (NOT a central runtime PDP)”There are two opposite ways to do “uniform authz,” and only one is compatible with a heterogeneous fleet:
-
A central runtime authorization engine that every system queries at decision time (OpenFGA/Zanzibar, OPA, Cedar, the emerging AuthZEN API) is maximally uniform but minimally compatible — off-the-shelf tools (AWS, ArgoCD, Grafana, almost every future tool) will not outsource their authorization decisions; they decide internally from token claims + native config. Rejected as the foundation. It is reserved only for apps we build and control (a Backstage custom policy, future bespoke services), where we own the enforcement point.
-
A single declarative source-of-truth, compiled outward is the externally-compatible way to “define once, propagate everywhere.” Enforcement stays native (each tool’s own engine), fed two things from the one source:
- Standard OIDC group/role claims — the one authorization-relevant wire nearly every external tool speaks natively (the compatibility win).
- Generated native RBAC per system (Identity Center assignments, the IdP’s groups/roles, ArgoCD policy, k8s RoleBindings, Backstage policy).
This is also more robust than a central PDP — no runtime authz service on the hot path of every check.
2. App-facing IdP — Keycloak, brokering to Identity Center
Section titled “2. App-facing IdP — Keycloak, brokering to Identity Center”Adopt Keycloak as the app-facing OSS OIDC identity provider. It federates authentication up to AWS Identity Center (SAML) — AWS SSO stays the user store and the AWS console/CLI path — while Keycloak owns the group/role taxonomy locally and emits clean OIDC group/role claims (by name), per client, to every app (Backstage, ArgoCD via OIDC instead of the UUID hack, and future tools). It replaces Dex (ADR-052), reusing the same vendor-neutral issuer seam.
Keycloak was chosen over the alternatives (evaluation 2026-06-04) for the criteria that matter most here: the most mature SAML brokering (richest upstream-attribute → local-group/role mapping), the cleanest per-client OIDC claim mappers, the most mature, now-official Terraform provider (code-managed realms/groups/roles/ clients/mappers), unencumbered Apache-2.0 + CNCF governance, the strongest native fine-grained authz (Authorization Services / UMA — for our own apps later, decision 1), and first-class ArgoCD + Backstage OIDC track records. Identity Center stays the AWS IdP — the split (AWS SSO for AWS, Keycloak for apps) is intentional.
3. Access model as code
Section titled “3. Access model as code”Define the team → group → role → permission taxonomy once, declaratively, extending the ADR-049 “Team envelope” (an SSO group + allowed tiers/quota/envs), and generate each system’s native config from it: Identity Center account assignments, Keycloak groups/roles/clients/mappers (Terraform provider), ArgoCD policy, Kubernetes RoleBindings, and the Backstage permission policy. This is the platform’s existing ethos (generate native config from a shared model) applied to authz, and it is the only approach that scales to “future tools” and kills the per-system duplication. It is adopted regardless of the IdP and is the higher-leverage half of this ADR.
Relationship to ADR-049 (the model this consumes)
Section titled “Relationship to ADR-049 (the model this consumes)”This ADR is the identity + enforcement half; ADR-049 (Team / Tenant /
Zone) is the model half — and the Team envelope it defines is the single declarative source decision 3
generates from. Keycloak’s group/role taxonomy mirrors the ADR-049 Team model; a Team is an SSO/Keycloak group
plus its envelope. The two ADRs are co-dependent and both land with the rebuild, so finalizing the ADR-049
schema is the first implementation step — the Keycloak group taxonomy, the per-system RBAC generators, and the
Backstage policy (#197) all read from it. (Backstage already forward-compat’d: the 2.3a catalog projection emits
Systems carrying zone/tier so ADR-049 lands with no entity-model change.)
They interlock across two authorization planes, both derived from the one Team model: (a) the envelope —
what a Team’s claims/workloads may do (tier ∈ allowedTiers, quota, envs/locations) — enforced at admission by
Kyverno on the Tenant CR; and (b) user RBAC — what a human may do per system — which is this ADR’s
job, where ADR-049’s environment × tier developer-access posture becomes the roles/claims generated outward.
Same source, two enforcement planes.
Consequences
Section titled “Consequences”- Unblocks #197 (Backstage group-based RBAC): designed to consume OIDC group claims Keycloak provides
cleanly. ArgoCD likewise moves from opaque group UUIDs to named claims, and its
developerrole can finally be team-scoped (ArgoCD Projects) consistent with k8s/AWS. - Keycloak is a Tier-0 component — HA, a CloudNativePG-backed DB, backups, and upgrade discipline (it is the new app front door, like the oauth2-proxy of #202). This is the honest operational cost; the lighter-ops runner-up was Zitadel (Go + Postgres) — revisit only if ops-simplicity ever outranks brokering richness.
- Two group sources (Identity Center for AWS, Keycloak for apps) — reconciled by decision 3 (the model-as-code generates both), so they can’t drift.
- Cloud-neutral app identity — aligns with the multi-cloud vision; the AWS-only constraint is removed from the app layer.
- Standards posture: OIDC claims = the compatibility wire; SCIM for provisioning into downstream apps; AuthZEN (OpenID Foundation, emerging) is the standard to track for a future uniform authz-decision API but is not yet bettable; Cedar / OpenFGA / Keycloak UMA are for our own apps’ fine-grained authz only.
- Timing: the IdP swap lands with the planned rebuild + ADR-049 (good window — that’s when the target tenant model arrives). The Dex broker (ADR-052) is the current implementation and is superseded over time. Interim Backstage RBAC (#197) either takes a thin tactical step or waits for Keycloak.
Alternatives considered
Section titled “Alternatives considered”- Stay on Identity Center + per-app workarounds (Identity Store API for Backstage groups; keep ArgoCD’s UUID mapping). Rejected: the group-claim gap and AWS-only constraint persist, the per-app SAML toil remains, and it doesn’t scale or stay DRY.
- A central runtime authz engine for all systems (OpenFGA/OPA/Cedar/AuthZEN). Rejected as the foundation: off-the-shelf tools won’t call it; viable only for our own apps (decision 1).
- Other OSS IdPs. Zitadel — lightest ops (Go + Postgres, fits CloudNativePG) but AGPL-3.0, a more opinionated role-claim model, thinner brokering + Terraform coverage, no native ReBAC. Authentik — open-core with per-user pricing creep and the most moving parts (Redis + worker + proxy). Ory (Hydra/Kratos) — no built-in SAML broker or RBAC taxonomy (you assemble the IdP yourself). Keycloak wins on this use case.
Scope / follow-up
Section titled “Scope / follow-up”This ADR sets direction; implementation is separate planning:
- The Keycloak module + unit (brokering to Identity Center, CNPG-backed, Tailscale-internal, HA), replacing the Dex module/unit (ADR-052).
- The access-model-as-code schema (extending the ADR-049 Team envelope) + the per-system generators (Keycloak, Identity Center, ArgoCD, k8s, Backstage).
- The ArgoCD OIDC cutover (named group claims; team-scoped Projects).
- Backstage RBAC (#197) on OIDC group claims.
- The Dex → Keycloak migration + retiring the per-app SAML toil.
- Auth loose ends from #213 (logout, token rotation) revisited under the new IdP.