Skip to main content

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" } } }

Source: infra/keycloak/organization-role-mapper/src/main/java/com/neuraflow/keycloak/mapper/OrganizationRoleMapper.java.

PropertyValue
Provider IDoidc-organization-role-mapper
Display nameOrganization Role Mapper
Priority100
Token typesaccess, ID, userinfo, introspection
Config propertydefaultRole, 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:

Access token payload (trimmed)
{
"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's UserRole enum and console's SysRole. Note that core collapses every non-sys_admin realm role to sys_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's RoleConstants, its changeUserRole validation set, core's hierarchy list and console's OrgRole plus the ORG_PERMISSIONS map all need it, or the role will be issued in tokens and then treated as unknown by everything that reads it.