Summary
Changing a user's role via PATCH /api/v1/users/:id adds the new role instead of replacing the old one. The user ends up holding two user_roles rows. Because User#role_data then picks one of them non-deterministically, the same user is reported with different roles by different endpoints — the role change appears to silently fail in the UI.
Two defects compound here. Reproduced on 2d9884f (current main).
Defect A — update_user_role never removes the previous role
app/controllers/api/v1/users_controller.rb
def update_user_role(role_key)
system_role = Role.find_by(key: role_key)
raise ActiveRecord::RecordNotFound, "Role '#{role_key}' not found" unless system_role
existing = @user.user_roles.joins(:role).where(roles: { system: false })
existing.destroy_all if existing.exists?
UserRole.assign_role_to_user(@user, system_role, current_user)
end
The cleanup is scoped to roles.system = false. But every seeded role is system = true:
key | type | system
---------------+---------+--------
super_admin | user | t
account_owner | user | t
agent | account | t
So existing is always empty, destroy_all never runs, and UserRole.assign_role_to_user — a find_or_create_by — appends a second row. Roles accumulate on every role change.
Defect B — role_data resolves the role non-deterministically
app/models/user.rb
user_role = if association(:user_roles).loaded?
user_roles.first
else
user_roles.joins(:role).first
end
No ORDER BY, and no filter on roles.type. With more than one row the result depends on which branch runs:
- eager-loaded —
UsersController#users does User.order_by_full_name.includes(:user_roles), so .first returns the first of the loaded array, i.e. insertion order → the oldest role wins.
- not eager-loaded —
user_roles.joins(:role).first makes Rails append ORDER BY user_roles.id, and id is a random gen_random_uuid() → an arbitrary role wins.
Observed result
One user, two roles (agent granted first, super_admin granted later, both via the UI — granted_by is set on both, so both were written by the application):
| endpoint |
role reported |
POST /api/v1/auth/login |
super_admin |
GET /api/v1/users |
agent |
The Users settings screen reads the list endpoint, so it keeps rendering the old role. From the operator's side the role change looks like it did nothing, and re-applying it is a no-op because find_or_create_by already has that row.
There is no "remove role" action in the UI, so this is not recoverable through the product — it needs a manual DELETE FROM user_roles.
Steps to reproduce
- Create a user with role
agent (the default in UsersController#create).
- As a
super_admin, PATCH /api/v1/users/:id with role: "super_admin".
SELECT * FROM user_roles WHERE user_id = '<id>' → two rows, expected one.
GET /api/v1/users → user still shows agent; POST /api/v1/auth/login as that user → super_admin.
Expected
A role change replaces the previous role of the same scope. Every endpoint reports the same role for a given user.
Suggested fix
- A: drop the
where(roles: { system: false }) scope so the previous grant is actually replaced. If holding both a type: 'user' and a type: 'account' role is intentional, scope the cleanup by roles.type matching the incoming role instead of by system.
- B: make
role_data deterministic regardless of eager-loading — apply an explicit, stable ordering (e.g. granted_at DESC) and/or select by role type, and use the same rule in both branches.
- A uniqueness constraint on
(user_id, roles.type) would stop the invalid state from being representable at all.
Related
Branch danilocarneiro/evo-2062-rbacauthcrmfe-acesso-do-super_admin-e-grant-backed-sem (1818e6e, "guard and self-heal the super_admin grant invariant") touches this controller, but leaves update_user_role byte-identical and does not modify app/models/user.rb, so it does not appear to address either defect.
Environment
evo-auth-service-community @ 2d9884f (main), unmodified
- PostgreSQL 15, single-account community deployment
Summary
Changing a user's role via
PATCH /api/v1/users/:idadds the new role instead of replacing the old one. The user ends up holding twouser_rolesrows. BecauseUser#role_datathen picks one of them non-deterministically, the same user is reported with different roles by different endpoints — the role change appears to silently fail in the UI.Two defects compound here. Reproduced on
2d9884f(currentmain).Defect A —
update_user_rolenever removes the previous roleapp/controllers/api/v1/users_controller.rbThe cleanup is scoped to
roles.system = false. But every seeded role issystem = true:So
existingis always empty,destroy_allnever runs, andUserRole.assign_role_to_user— afind_or_create_by— appends a second row. Roles accumulate on every role change.Defect B —
role_dataresolves the role non-deterministicallyapp/models/user.rbNo
ORDER BY, and no filter onroles.type. With more than one row the result depends on which branch runs:UsersController#usersdoesUser.order_by_full_name.includes(:user_roles), so.firstreturns the first of the loaded array, i.e. insertion order → the oldest role wins.user_roles.joins(:role).firstmakes Rails appendORDER BY user_roles.id, andidis a randomgen_random_uuid()→ an arbitrary role wins.Observed result
One user, two roles (
agentgranted first,super_admingranted later, both via the UI —granted_byis set on both, so both were written by the application):POST /api/v1/auth/loginsuper_adminGET /api/v1/usersagentThe Users settings screen reads the list endpoint, so it keeps rendering the old role. From the operator's side the role change looks like it did nothing, and re-applying it is a no-op because
find_or_create_byalready has that row.There is no "remove role" action in the UI, so this is not recoverable through the product — it needs a manual
DELETE FROM user_roles.Steps to reproduce
agent(the default inUsersController#create).super_admin,PATCH /api/v1/users/:idwithrole: "super_admin".SELECT * FROM user_roles WHERE user_id = '<id>'→ two rows, expected one.GET /api/v1/users→ user still showsagent;POST /api/v1/auth/loginas that user →super_admin.Expected
A role change replaces the previous role of the same scope. Every endpoint reports the same role for a given user.
Suggested fix
where(roles: { system: false })scope so the previous grant is actually replaced. If holding both atype: 'user'and atype: 'account'role is intentional, scope the cleanup byroles.typematching the incoming role instead of bysystem.role_datadeterministic regardless of eager-loading — apply an explicit, stable ordering (e.g.granted_at DESC) and/or select by roletype, and use the same rule in both branches.(user_id, roles.type)would stop the invalid state from being representable at all.Related
Branch
danilocarneiro/evo-2062-rbacauthcrmfe-acesso-do-super_admin-e-grant-backed-sem(1818e6e, "guard and self-heal the super_admin grant invariant") touches this controller, but leavesupdate_user_rolebyte-identical and does not modifyapp/models/user.rb, so it does not appear to address either defect.Environment
evo-auth-service-community@2d9884f(main), unmodified