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.
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:
| Helper | Passes when the caller is |
|---|---|
ensureOrgMemberOrAdmin | sys_admin, or a member of the org in any role |
ensureAdminOrOrgManager | sys_admin, or org_owner / org_admin of the org |
ensureOwnerOrAdmin | sys_admin, or org_owner of the org |
ensureSelfOrAdmin | sys_admin, or acting on their own user ID |
getCallerOrgRole | sys_admin (returns org_owner), or org_owner / org_admin (returns their role) |
ensureSysAdminOrOrgManagerOf | sys_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
| Endpoint | Required |
|---|---|
POST /api/v1/auth/register | public |
GET /api/v1/users/me | any authenticated |
PUT /api/v1/users/me | any authenticated (role field stripped) |
GET /api/v1/users | sys_admin → all users; org_owner/org_admin → own org only |
GET /api/v1/users/{id} | ensureSysAdminOrOrgManagerOf |
POST /api/v1/users/{id}/execute-actions | ensureSysAdminOrOrgManagerOf |
GET /api/v1/users/roles | sys_admin |
POST / PUT / DELETE /api/v1/users[/{id}] | sys_admin |
POST /api/v1/users/invite | sys_admin |
POST /api/v1/users/{id}/activate | /deactivate | sys_admin |
POST /api/v1/organizations | sys_admin |
GET /api/v1/organizations | sys_admin → all; otherwise own orgs |
GET /api/v1/organizations/me | any authenticated (own orgs) |
GET /api/v1/organizations/user/{userId} | ensureSelfOrAdmin |
GET /api/v1/organizations/{orgId} | ensureOrgMemberOrAdmin |
GET /api/v1/organizations/{orgId}/users | ensureOrgMemberOrAdmin |
POST /api/v1/organizations/{orgId}/users | ensureAdminOrOrgManager |
POST /api/v1/organizations/{orgId}/invite | ensureAdminOrOrgManager |
PATCH /api/v1/organizations/{orgId}/members/{userId}/role | getCallerOrgRole + 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:
org_ownercannot be assigned. The valid target set isorg_admin,org_editor,org_member,org_guest. Ownership is established at organization creation and by the pending-owner promotion below — never by this endpoint.- An
org_owner's role cannot be changed. Not even by asys_admin, since the check is on the target's current role, before any caller check. - Only an
org_ownercan promote toorg_admin. - Only an
org_ownercan change an existingorg_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.