One flat admin role serves two tiers, and role administration ignores tenancy
SA-R11-01
Authorities come from Keycloak realm roles — KeycloakRealmRoleConverter maps realm_access.roles to ROLE_*, and both Spring's hasRole("admin") and TenantContext.requireRole("admin") read that one claim. There is no platform-administrator concept anywhere in the codebase; a search for one returns nothing.
That single role is required for ordinary tenant work: UserController calls requireRole("admin") to let a customer manage their own organization's users. It also gates operations that are realm-wide. So the administrator of any customer organization can:
Grant any realm role to any user in the realm. RoleService.assignRoleToUser is a bare passthrough to Keycloak. It never checks that the target Keycloak user belongs to the caller's organization, and never checks that roleName corresponds to a Role row the caller's organization owns — including granting admin itself. createRole carefully stamps the caller's organizationId on the database row, with a comment saying the org is never trusted from the body, but the Keycloak role it creates is realm-global and the assign/remove paths never read that column back.
Read every tenant's users and roles. getAllUsers() is userRepository.findAll(). getAllRoles() is roleRepository.findAll(). GET /api/roles/keycloak lists every realm role. searchUsers(query) searches the whole Keycloak realm and then writes local User rows for whoever it finds, so a cross-tenant read has a cross-tenant write as a side effect.
Read platform operations. /api/operations/** and /actuator/metrics/** both resolve to the same role. The new recovery panel's own error copy says “Check your platform administrator access” — naming a tier the authorization model does not have.
/api/operations/** and /actuator/metrics/** onto it. Then scope role administration: assignRoleToUser should reject a target user outside the caller's organization and a roleName whose Role.organizationId is not the caller's, and getAllRoles/getAllUsers should filter by the caller's organization. The Role.organizationId column already exists — nothing reads it on the paths that matter.