Flush auto-created roles during user import to avoid duplicate role inserts in batch mode - #50056
Flush auto-created roles during user import to avoid duplicate role inserts in batch mode#50056wilmerdooley wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR updates role import logic to reuse existing realm/client roles (by name) and consolidate role updates, preventing duplicate role creation and ensuring imported metadata is applied.
Changes:
- Replaced unconditional role creation during import with “get-or-create” logic for realm and client roles.
- Centralized role updates (description + attributes) into a shared helper.
- Added a unit test to verify that importing a client role reuses an existing role and updates its description.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java | Adds get-or-create helpers for realm/client roles and centralizes role field updates during import. |
| server-spi-private/src/test/java/org/keycloak/models/utils/RepresentationToModelTest.java | Adds a test ensuring client role import reuses an existing role and updates description without creating a new role. |
| private static Object defaultValue(Class<?> returnType) { | ||
| if (void.class.equals(returnType)) { | ||
| return null; | ||
| } | ||
| if (!returnType.isPrimitive()) { | ||
| return null; | ||
| } | ||
| if (boolean.class.equals(returnType)) { | ||
| return false; | ||
| } | ||
| if (char.class.equals(returnType)) { | ||
| return '\0'; | ||
| } | ||
| return 0; | ||
| } |
| private static RoleModel importRealmRole(RealmModel realm, RoleRepresentation roleRep) { | ||
| RoleModel role = realm.getRole(roleRep.getName()); | ||
| if (role == null) { | ||
| role = roleRep.getId() != null ? realm.addRole(roleRep.getId(), roleRep.getName()) : realm.addRole(roleRep.getName()); | ||
| } | ||
| updateRole(role, roleRep); | ||
| return role; | ||
| } | ||
|
|
||
| private static RoleModel importClientRole(ClientModel client, RoleRepresentation roleRep) { | ||
| RoleModel role = client.getRole(roleRep.getName()); | ||
| if (role == null) { | ||
| role = roleRep.getId() != null ? client.addRole(roleRep.getId(), roleRep.getName()) : client.addRole(roleRep.getName()); | ||
| } | ||
| updateRole(role, roleRep); | ||
| return role; | ||
| } | ||
|
|
||
| private static void updateRole(RoleModel role, RoleRepresentation roleRep) { | ||
| role.setDescription(roleRep.getDescription()); | ||
| if (roleRep.getAttributes() != null) { | ||
| roleRep.getAttributes().forEach(role::setAttribute); | ||
| } | ||
| } |
| private static RoleModel importRealmRole(RealmModel realm, RoleRepresentation roleRep) { | ||
| RoleModel role = realm.getRole(roleRep.getName()); | ||
| if (role == null) { | ||
| role = roleRep.getId() != null ? realm.addRole(roleRep.getId(), roleRep.getName()) : realm.addRole(roleRep.getName()); | ||
| } | ||
| updateRole(role, roleRep); | ||
| return role; | ||
| } | ||
|
|
||
| private static RoleModel importClientRole(ClientModel client, RoleRepresentation roleRep) { | ||
| RoleModel role = client.getRole(roleRep.getName()); | ||
| if (role == null) { | ||
| role = roleRep.getId() != null ? client.addRole(roleRep.getId(), roleRep.getName()) : client.addRole(roleRep.getName()); | ||
| } | ||
| updateRole(role, roleRep); | ||
| return role; | ||
| } |
There was a problem hiding this comment.
Consolidating this using lambda expressions would add unnecessary cognitive complexity and reduce maintainability.
Additionally, while similar, the realm role and client role logic is distinct enough in source class and intent, that keeping the methods distinct makes sense to me.
There was a problem hiding this comment.
Thanks for reporting this and for taking the time to review, @SomeDeveloper13. I agree on keeping importRealmRole and importClientRole as separate helpers rather than folding them into shared lambda-based code; as you noted, the lambda version adds cognitive complexity, and the two paths read from different sources (realm roles vs a client's roles), so keeping them distinct makes the intent clearer.
sguilhen
left a comment
There was a problem hiding this comment.
Thanks for looking into this, @wilmerdooley.
After tracing through the stack trace and the realm JSON from the issue, I believe the fix targets the wrong code path. The PK violation doesn't occur during importRoles, it occurs during user import, which runs inside EntityManagers.runInBatch (the stack trace shows EntityManagers.flush -> runInBatch -> DefaultExportImportManager.importRealm:481).
The issue is specifically in RepresentationToModel.createClientRoleMappings. This method auto-creates client roles that are referenced in user clientRoles but weren't defined in roles.client. Batch mode sets all queries to FlushMode.COMMIT via EntityManagerProxy, which breaks the get-or-create pattern: when two users reference the same undefined client role, the second user's getRole query doesn't see the first user's unflushed persist, causing a duplicate insert at the end-of-batch flush.
The importRoles changes in this PR are a reasonable defensive improvement (handling pre-existing roles from defaultRoles or other sources), but importRoles runs before batch mode starts, where auto-flush works correctly - so these changes don't address the reported crash.
The minimal fix would be to ensure that when createClientRoleMappings (and createRoleMappings for realm roles) auto-creates a missing role inside a batch, the new role is flushed to the database immediately so that subsequent users referencing the same role can see it. This requires plumbing a flush call into the role == null branch, conditional on EntityManagers.isBatchMode().
Regarding the test: the unit test uses in-memory dynamic proxies where getRole is backed by a HashMap, so lookups always see freshly added entries immediately. This can't reproduce the actual bug, which is fundamentally about JPA query visibility under FlushMode.COMMIT in batch mode. An integration test that imports a realm JSON where two users share a client role not defined in roles.client would be needed to exercise the real failure path.
|
Thanks for the detailed trace, @sguilhen. You're right, I had the fix in the wrong place. I'll re-target to your suggested fix: flush the newly created role in the One question on scope: would you prefer I keep the |
|
@wilmerdooley I think it doesn't hurt to have the defensive check you've proposed. Feel free to keep it. And thanks again for volunteering! |
Signed-off-by: wilmerdooley <wilmerdooley1@gmail.com>
91f6b84 to
daaa0d0
Compare
|
Thanks again, @sguilhen. I re-targeted the fix to the path you identified: the Since you were happy to keep the defensive |
|
Thanks for the updated PR, @wilmerdooley - the core fix (flushing auto-created roles in batch mode) looks good to me. There are two CI failures that need to be addressed before we can merge:
Let me know if you need any help with the migration! |
|
Opened #51210 to fix failing checks. |
|
Closing in favor of #51210 - @wilmerdooley your contribution will be there as well as @theohh0 has cherry picked your commit and worked on top of it. |
Resolves #49731.
The duplicate-role PK violation happens during user import, not role-definition import.
RepresentationToModel.createRoleMappings/createClientRoleMappingsauto-create roles that a user'srealmRoles/clientRolesreference but that were never declared inroles.realm/roles.client. These run insideEntityManagers.runInBatch, where every query is inFlushMode.COMMIT. When two users reference the same undeclared client role, the second user'sgetRolecannot see the first user's unflushedaddRole, so a duplicate role is created and the end-of-batch flush throws the unique-constraint violation on(NAME, CLIENT_REALM_CONSTRAINT), matching the'CALL_ACCESS'row in the issue.Thanks to @sguilhen for tracing this to the real path. The fix follows the suggested approach: in the
role == nullbranch ofcreateRoleMappings/createClientRoleMappings, flush the newly created role whenEntityManagers.isBatchMode()so later lookups in the same batch can see it. AKeycloakSessionis plumbed into both methods (the singlecreateRoleMappingscall site has the session in scope).I kept the defensive get-or-create change in
importRolesfrom the first version of this PR, since @sguilhen confirmed it is a reasonable improvement to keep.Changes:
RepresentationToModel.createRoleMappings/createClientRoleMappings: flush an auto-created role in batch mode (plus theEntityManagersimport and asessionparameter).DefaultExportImportManager: pass the session intocreateRoleMappings.RealmImportSharedUndefinedClientRoleTest: imports a realm where two users reference the same client role not declared inroles.client, and asserts the import succeeds, exactly one such client role exists, and both users receive the mapping. It reproduces the original KEYCLOAK_ROLE unique-constraint violation without the fix.importRoleschange and its unit test are retained.