Skip to main content

Identity

Identity (Spring Boot) is the only component that writes a role. Neither core nor console ever changes one; both only read what Keycloak put in the token. Every role assignment, promotion, demotion and removal in the platform goes through an endpoint on this service.

It talks to Keycloak through the Admin REST API using a service-account client configured in KeycloakAdminConfig.java — client_credentials grant, no user context. That client holds realm-management privileges, so every authorization decision on a write is made by identity's own code, not by Keycloak. Keycloak will happily perform whatever the service account asks. If a check is missing from a controller, nothing downstream catches it.

Why Spring Boot, not Python

Identity does all its work through Keycloak's Admin REST API: creating users, writing org_role_{orgId} attributes, assigning realm roles, managing organizations. Keycloak's own project publishes and maintains a first-party client for exactly that — org.keycloak:keycloak-admin-client (identity pins 26.0.8, tracking the same 26.x line as the deployed server). It's a typed client that ships from the same release as the Keycloak server, so an admin-API change lands in the client immediately rather than waiting on a third party to catch up.

Python has no equivalent. The closest option, python-keycloak, is a community-maintained wrapper around the same REST endpoints, not published by the Keycloak project, and it has a history of lagging server releases and breaking on major admin-API changes. For a service whose entire job is admin-API writes, that gap is the whole risk surface — it's the reason identity is Java while the rest of the platform is Python.

Core never runs into this: it doesn't call the Keycloak Admin API at all, only validates tokens against the standard JWKS/OIDC endpoints — a generic protocol operation with solid library support in any language. The two backend services need different things from Keycloak, which is why they're in different languages.

Role strings​

RoleConstants.java holds every role string, the org_role_ attribute prefix, the sys_ role prefix, and Spring's ROLE_ authority prefix. The values are identical to the Keycloak realm role names and to console's constants, with no mapping layer.

Incoming JWTs are converted to Spring authorities by KeycloakRealmRoleConverter in SecurityConfig.java: every entry of realm_access.roles becomes a ROLE_-prefixed SimpleGrantedAuthority. That prefix is why @PreAuthorize("hasRole('sys_admin')") works and why the isSysAdmin() helpers compare against RoleConstants.ROLE_SYS_ADMIN ("ROLE_sys_admin") rather than the bare role name.

The service is a stateless OAuth2 resource server: sessions are disabled, CSRF is off, and everything outside /api/v1/auth/**, the actuator health endpoints and the Swagger paths requires authentication.

Two gating styles​

Identity gates its endpoints in two different ways, and the split is not arbitrary — but it is easy to misread.

System-role writes are annotation-gated. Creating, updating, deleting and inviting users, plus reading the system role list, all carry @PreAuthorize("hasRole('sys_admin')"). The check is declarative and visible at the method signature.

Organization-role writes are gated in the method body. Every endpoint on OrganizationController is annotated @PreAuthorize("isAuthenticated()") — which grants nothing — and the real check is a helper call on the first line of the body. This is necessary because the required authority depends on a path variable (orgId) and on data only reachable via a Keycloak lookup, which an annotation can't express cleanly.

The consequence worth internalising: on OrganizationController, the annotation tells you nothing. A missing helper call is not visible at the signature, does not fail any compile-time check, and produces an endpoint that accepts any authenticated caller. When reviewing a change here, read the first line of the body, not the annotation.

The helpers​

All five live at the bottom of OrganizationController.java, with one more on UserController.java:

HelperPasses when the caller is
ensureOrgMemberOrAdminsys_admin, or a member of the org in any role
ensureAdminOrOrgManagersys_admin, or org_owner / org_admin of the org
ensureOwnerOrAdminsys_admin, or org_owner of the org
ensureSelfOrAdminsys_admin, or acting on their own user ID
getCallerOrgRolesys_admin (returns org_owner), or org_owner / org_admin (returns their role)
ensureSysAdminOrOrgManagerOfsys_admin, or org_owner / org_admin of an org the target user also belongs to

ensureSysAdminOrOrgManagerOf is the interesting one. It exists to prevent IDOR across organizations: an org admin listing or emailing a user by ID must share an org with that user, otherwise any org admin could read any user in the realm by guessing UUIDs.

Note that all of these resolve the caller's org role by calling getMyOrganizations(), which hits Keycloak — the caller's role is not taken from their JWT. That costs a round trip per check, but it means an org role revoked a second ago is honoured immediately on writes, unlike core and console which are stuck with whatever the token says until it refreshes.

Endpoint authority​

EndpointRequired
POST /api/v1/auth/registerpublic
GET /api/v1/users/meany authenticated
PUT /api/v1/users/meany authenticated (role field stripped)
GET /api/v1/userssys_admin → all users; org_owner/org_admin → own org only
GET /api/v1/users/{id}ensureSysAdminOrOrgManagerOf
POST /api/v1/users/{id}/execute-actionsensureSysAdminOrOrgManagerOf
GET /api/v1/users/rolessys_admin
POST / PUT / DELETE /api/v1/users[/{id}]sys_admin
POST /api/v1/users/invitesys_admin
POST /api/v1/users/{id}/activate | /deactivatesys_admin
POST /api/v1/organizationssys_admin
GET /api/v1/organizationssys_admin → all; otherwise own orgs
GET /api/v1/organizations/meany authenticated (own orgs)
GET /api/v1/organizations/user/{userId}ensureSelfOrAdmin
GET /api/v1/organizations/{orgId}ensureOrgMemberOrAdmin
GET /api/v1/organizations/{orgId}/usersensureOrgMemberOrAdmin
POST /api/v1/organizations/{orgId}/usersensureAdminOrOrgManager
POST /api/v1/organizations/{orgId}/inviteensureAdminOrOrgManager
PATCH /api/v1/organizations/{orgId}/members/{userId}/rolegetCallerOrgRole + hierarchy rules below
DELETE /api/v1/organizations/{orgId}/users/{userId}ensureAdminOrOrgManager, and not self
GET/DELETE/POST …/invitations[/{email}]ensureAdminOrOrgManager
PATCH /api/v1/organizations/{orgId}ensureAdminOrOrgManager
DELETE /api/v1/organizations/{orgId}ensureOwnerOrAdmin

Role change rules​

PUT /api/v1/users/{userId} is the only way to change a system role, and it is sys_admin-only. The self-service PUT /api/v1/users/me builds a new UserUpdateRequest with the role field replaced by null before applying it, so a user cannot escalate themselves through their own profile endpoint even by posting a role in the body.

Organization role changes go through changeUserRole in KeycloakOrganizationService.java, which enforces four invariants after the controller has established the caller's authority:

  1. org_owner cannot be assigned. The valid target set is org_admin, org_editor, org_member, org_guest. Ownership is established at organization creation and by the pending-owner promotion below — never by this endpoint.
  2. An org_owner's role cannot be changed. Not even by a sys_admin, since the check is on the target's current role, before any caller check.
  3. Only an org_owner can promote to org_admin.
  4. Only an org_owner can change an existing org_admin's role.

Together, 3 and 4 mean an org_admin can manage editors, members and guests but cannot create a peer or demote one. Because getCallerOrgRole returns org_owner for a sys_admin, a platform admin satisfies 3 and 4 — but is still blocked by 2.

Separately, DELETE …/users/{userId} refuses when the caller is the target, so an owner cannot accidentally remove themselves and orphan the organization.