Skip to content

Role change adds a second role instead of replacing it; role then resolves differently per endpoint #74

Description

@marcelshev

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-loadedUsersController#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-loadeduser_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

  1. Create a user with role agent (the default in UsersController#create).
  2. As a super_admin, PATCH /api/v1/users/:id with role: "super_admin".
  3. SELECT * FROM user_roles WHERE user_id = '<id>'two rows, expected one.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions