Keycloak
Keycloak is the only store for both role dimensions, and the access token it issues is the contract every other component reads. Nothing downstream queries Keycloak about roles at request time — identity writes, Keycloak issues, and core and console read claims off the token they were given.
The deployment is Keycloak 26.5.4 with the organization feature enabled,
built from
infra/keycloak/Dockerfile
and run via
infra/keycloak/compose.keycloak.yaml.
Two role kinds, two storage mechanisms
System roles are ordinary Keycloak realm roles. Nothing custom is involved:
assign sys_admin to a user and it appears in the standard
realm_access.roles claim.
Organization roles are user attributes named org_role_{orgId}, one per
organization the user belongs to:
org_role_3f2a91c4-... = org_owner
org_role_8b71de05-... = org_editor
The attribute mechanism is a workaround, and it is worth understanding why.
Keycloak 26's native Organizations feature models membership — it knows a
user belongs to an org — but it has no concept of a per-organization role. A
realm role can't express it either, because realm roles are realm-wide: a
org_admin realm role would make the user an admin of every org at once. User
attributes are the only per-user, per-org key-value store Keycloak offers out
of the box, so the org ID is encoded into the attribute name.
The cost of that choice: attributes are opaque strings with no referential
integrity. Deleting an organization does not remove the matching
org_role_{orgId} attributes from its members, and nothing validates that the
value is one of the five known roles.
The custom protocol mapper
The built-in organization mapper contributes an organization claim listing
the orgs a user belongs to, but with no role information:
{ "organization": { "hello": { "id": "uuid", "code": ["hello"] } } }
A custom mapper in this repo enriches each entry with the role read from the matching user attribute:
{ "organization": { "hello": { "id": "uuid", "code": ["hello"], "role": "org_owner" } } }
| Property | Value |
|---|---|
| Provider ID | oidc-organization-role-mapper |
| Display name | Organization Role Mapper |
| Priority | 100 |
| Token types | access, ID, userinfo, introspection |
| Config property | defaultRole, default org_member |
Four details of its behaviour matter downstream:
Priority 100 is load-bearing. The built-in OrganizationMembershipMapper
runs at priority 0. This mapper reads the organization claim the built-in one
produced and rewrites it, so it must run after. If the built-in mapper is not
assigned to the client, there is no claim to enrich and this mapper returns
without doing anything — org roles silently vanish from the token rather than
erroring.
The role is read live at token issuance. user.getFirstAttribute() is
called during setClaim, so a role change written by identity takes effect on
the next token issued for that user. It does not affect tokens already in
circulation.
Every org entry always carries a role. If no org_role_{orgId} attribute
exists — a member added directly in the Keycloak admin console, say — the
configured defaultRole is used, org_member unless overridden. Consumers
never have to handle a missing role, only an unexpected one.
The value is not validated. Whatever string is in the attribute is trimmed
and passed through. A typo'd org_admn reaches the token intact and is then
treated as an unknown role by each consumer independently — see
Core and Console for the two different things that
then happen.
The mapper is compiled by a Maven stage in the Keycloak image build and dropped
into /opt/keycloak/providers/, so it ships with the image rather than being
installed into a running instance.
Token anatomy
The claims the rest of the system depends on — the same user, one system role, and two different organization roles in two different orgs:
{
"sub": "3f2a91c4-9b2e-4a11-8e7a-2d6f1c9a44b0",
"email": "jane@brainstation-23.com",
"given_name": "Jane",
"family_name": "Doe",
"exp": 1755600000,
"realm_access": {
"roles": [
"sys_admin",
"default-roles-neuraflow",
"offline_access",
"uma_authorization"
]
},
"organization": {
"brainstation-23.com": {
"id": "8b71de05-6c2a-4f19-9d3e-1a7b5c9e0f21",
"code": ["brainstation-23"],
"role": "org_owner"
},
"acme-corp.com": {
"id": "d42b6ae8-94a8-4ea6-a819-3ececf5eddef",
"role": "org_editor"
}
}
}
Highlighted: the system role inside realm_access.roles, and the two
organization roles under organization.*.role — org_owner in one org,
org_editor in the other. Same token, same user, independent roles per org.
sub is the Keycloak user UUID and is used as the foreign key for users
throughout core — there is no separate user table to join against.
Note the shape of organization: it is keyed by org name, and the org's
UUID is a field inside each entry, not the key. Core flips this into an
id → membership map on parse because every authorization check it runs is by
org ID. Console keeps the key as the display name and reads code[0] as the
alias — code is optional, so a missing one (as on acme-corp.com above)
just means an empty alias, not an error.
realm_access.roles also carries Keycloak's own roles (default-roles-*,
offline_access, uma_authorization). Consumers filter for what they care
about rather than assuming the list is only application roles.
Client scope
The console requests the organization:* scope at Keycloak init
(console/src/context/keycloak-provider.tsx).
Without it the organization claim is absent, and with it absent every org
role in the system is invisible — the console shows a user with no
organizations and core rejects every org-scoped request as non-membership. If
org roles appear to have vanished wholesale, this scope and the mapper
assignment are the first two things to check.
Adding a new role
Because the strings are shared verbatim and no component maps them, adding a role touches several places at once:
- A new
sys_*realm role — create it in Keycloak, then add it to core'sUserRoleenum and console'sSysRole. Note that core collapses every non-sys_adminrealm role tosys_user, so a new system role is not enforceable server-side without changing that logic. See Core. - A new
org_*role — no Keycloak change is needed, since the attribute value is a free string. But identity'sRoleConstants, itschangeUserRolevalidation set, core's hierarchy list and console'sOrgRoleplus theORG_PERMISSIONSmap all need it, or the role will be issued in tokens and then treated as unknown by everything that reads it.