Fix role assignment across authentication origins and under concurrent logins - #2540
Merged
Merged
Conversation
RoleService.addUserToRole resolved an existing assignment with
findByUserAndRole, which ignores origin. A user who already held a role
from one origin therefore never received it from another: the lookup
returned the existing row and inserted nothing, so LoginService.syncRoles
kept re-adding the role on every login because getRolesByOrigin still did
not see it.
Concurrent logins racing the same check-then-insert also left duplicate
rows, and once duplicated the Optional-returning query threw
IncorrectResultSizeDataAccessException, which broke role synchronisation
and made DELETE /role/{roleId}/users/{userId} return 500.
Look assignments up by (user, role, origin), and remove every matching
assignment rather than a single arbitrary one. findFirst keeps the lookup
tolerant of databases that already contain duplicates; a migration
collapses those rows.
sec_user.login is unique, and so is the personal role name derived from it, so two first logins for the same principal cannot both register it. Registration ran in the caller's transaction, so the loser marked that transaction rollback-only and its login failed with a constraint violation. Deployments that authenticate per request rather than per session hit this whenever a new user's first page load fans out. Serialise registration on a transaction scoped advisory lock, taken on the connection the transaction already holds, then look the user up again. Whoever waits observes the winner's row once that transaction commits, so the conflicting insert never happens. Registering in a nested REQUIRES_NEW transaction was tried first and rejected: it holds a second pooled connection for every in-flight first login while the caller's transaction still holds the first, so once simultaneous first logins reach the pool size none can obtain the second connection and none can release the first. They then fail together after the connection timeout instead of individually and immediately. Also document that role assignment is tracked per origin, and that removing a user from a role spans every origin.
p-hoffmann
force-pushed
the
fix/user-role-origin-aware-assignment
branch
from
August 17, 2026 05:12
da8c37c to
28f0b6b
Compare
Granting a role is a lookup followed by an insert, and sec_user_role has no unique constraint, so two logins that both found a role missing both added it. A deployment that authenticates per request accumulated a row per concurrent request. Serialise the logins that have roles to add on a transaction scoped advisory lock and re-read the assignments once it is held. Logins with nothing to add, which is all of them after the first, take no lock. Locking once per login rather than around each assignment keeps the acquisition order fixed. A first login holds both this lock and the registration one, but onSuccess always registers before it syncs and each lock is taken in a single place, so no two transactions can hold them in opposite orders.
p-hoffmann
force-pushed
the
fix/user-role-origin-aware-assignment
branch
from
August 17, 2026 05:38
6a6190e to
7e0a306
Compare
This was referenced Aug 17, 2026
Collaborator
|
Only comment i have on this is the handling of the dupes:
Since the migration script de-dupes, we can be assured that we won't have that duplication, so we could change the code to not handle this (making it appear that it may be a valid data arrangement). It is a valid data arrangement, however, to have a user have the same roles assigned more than once (from different origins) so want to be clear about if findFirst would still be needed in that case in the event of a double-role-assignment. But if it's just to account for a dupe of user-role-origin, then we probably won't need it after we de-dupe and apply a unique consraint. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three defects in the login path, surfacing as roles silently failing to apply, as duplicated role assignments, or as a failed login.
1. Role assignments ignored their origin
sec_user_role.originrecords which authentication source granted an assignment, andLoginService.syncRolesuses it to reconcile only the roles asserted by the origin the user just logged in from.RoleService.addUserToRoledid not honour that: it resolved an existing assignment withfindByUserAndRole, which ignoresorigin.Effects:
getRolesByOrigincontinued not to see it, so it was retried on every subsequent login.Optional-returning query threwIncorrectResultSizeDataAccessException. That broke role synchronisation for the affected role permanently, and madeDELETE /role/{roleId}/users/{userId}return 500, so the duplicates could not be cleared through the API either.Changes:
addUserToRoleresolves the assignment by(user, role, origin).removeUserFromRoleandremoveUserdelete every matching assignment instead of one arbitrary row.removeUser(the admin endpoint) intentionally spans all origins, so that it stays consistent withgetRoleUsers, which is not origin-scoped; anything the identity provider still asserts is re-added on the next login.findFirstso databases already holding duplicates do not throw.V2.99.0010__dedupe_sec_user_role.sqlcollapses existing duplicates, keeping the lowest id per(user_id, role_id, origin).2. Concurrent first logins could not both register the user
ensureUserExistsregistered the user inside the caller's transaction.sec_user.loginis unique, and so is the personal role name derived from it, so when two first logins for the same principal ran at once the loser marked the caller's transaction rollback-only and the login failed outright withduplicate key value violates unique constraint "sec_user_login_unique".Registration is now serialised on a transaction scoped advisory lock, taken on the connection the transaction already holds, after which the user is looked up again. Whoever waits observes the winner's row once that transaction commits, so the conflicting insert never happens.
Serialising was chosen over registering in a nested
REQUIRES_NEWtransaction. That alternative works for a single login, but it holds a second pooled connection for the duration of every in-flight first login while the caller's transaction still holds the first. Once simultaneous first logins reach the pool size (10 by default), none of them can obtain the second connection and none can release the first, so they all fail together afterspring.datasource.hikari.connection-timeoutrather than immediately.UserRegistrationRaceTestruns more threads than the default pool holds and reproduces exactly that.This uses
pg_advisory_xact_lock, which is Postgres specific. This branch ships Postgres migrations only, but say the word if a portable form is preferred.Deployments that authenticate on every request rather than once per session hit the underlying race reliably.
3. Concurrent logins duplicated role assignments
Granting a role is a lookup followed by an insert and
sec_user_rolehas no unique constraint, so two logins that both found a role missing both added it. A deployment that authenticates per request accumulated a row per concurrent request; the test below produces ten.The logins that have roles to add now serialise on a transaction scoped advisory lock and re-read the assignments once it is held. Logins with nothing to add, which is all of them after the first, take no lock.
The lock is taken once per login rather than once per assignment deliberately. Locking per assignment, or adding a unique constraint and letting the insert conflict, would make a transaction hold several of these locks at once, and
syncRolesiterates an unordered set — so two logins could take the same locks in opposite orders and deadlock.A first login does hold two of these locks, the registration one and this one, but
onSuccessalways registers before it syncs and each lock is taken in exactly one place, so the acquisition order is the same in every transaction and no cycle is possible.A unique constraint on
(user_id, role_id, origin)would be a reasonable follow-up now that duplicates can no longer be created, but it is not needed to close this and is left out to keep the migration additive.Two related things are deliberately left alone.
addUserToRoleremains unsynchronised for its non-login callers, the admin endpoint and the bulk user import; those grant atSYSTEMorigin while login sync grants at the origin that authenticated, so they write different rows rather than duplicates. AndExternalRoleMapService.syncUserRolesrepeats the pattern this fixes but has no callers at all — worth deleting or routing through the same lock before anything starts using it.Testing
UserRoleOriginTestcovers a grant from a second origin, per-origin removal, and tolerance of pre-existing duplicates.UserRegistrationRaceTestreleases sixteen concurrent logins for the same principal from a barrier — more than the default connection pool holds — and asserts that all succeed, that exactly one user is registered, and that a role asserted by all of them is assigned exactly once.Each fails on the unmodified branch with the corresponding production symptom and passes with this change.
ExternalRoleMappingTestandExternalRoleMappingCsvTestfail identically with and without this change onwebapi-3.0.