User roles in Rails, and what each design costs you
Most Rails apps need two roles and get talked into a permissions framework. Some need real
permissions and ship a boolean called admin that takes a year to untangle. The useful question
isn't which gem, it's which of three shapes your app actually has, so let's price all three.
Everything below was measured on Rails 8.1.4 in a rails new --minimal app, counting real queries
off sql.active_record notifications.
One column, when a user has one role
class User < ApplicationRecord
enum :role, { member: 0, support: 1, admin: 2 }, default: :member
end
That's the whole implementation. You get user.admin?, user.admin!, the User.admin scope and
a default for every new row, which we confirmed: a freshly created user comes back as member
without anybody setting it.
The reason to reach for this first is in the query count. user.admin? costs zero queries.
The role is a column on a row you've already loaded, so the check is an integer comparison in
Ruby and nothing crosses the wire. User.admin compiles down to exactly what you'd write by
hand:
SELECT "users".* FROM "users" WHERE "users"."role" = 2
Rails generates precisely three predicates here: admin?, member? and support?. No can?,
no policies, nothing clever. That's worth seeing plainly, because it's the whole API surface and
it's the reason this design stays cheap.
The renumbering that promotes everybody
Here's the one real hazard in the column design, and it's a data corruption rather than a bug you can see.
The enum stores an integer. Admin is 2 in the database, not the string "admin". So the
mapping in your model file is the only thing that says what 2 means, and if that mapping
shifts, every existing row changes meaning without a single write.
# six months later, someone needs a new role and puts it where it reads nicely
enum :role, { guest: 0, member: 1, support: 2, admin: 3 }
Every admin in your database is stored as 2. After that edit, 2 means support. You've just
demoted every administrator and promoted nobody, the test suite stays green because the fixtures
get written through the same mapping, and you find out when somebody can't reach the admin panel.
Two rules keep you safe and they cost nothing. Always write the hash out with explicit integers,
never pass an array and let Rails number it positionally. And only ever append, so new roles take
the next unused number even when that reads badly. If you want the ordering to be meaningful, that
belongs in a sort_by in the view, not in the storage.
A string-backed column avoids the whole problem and costs you a few bytes per row. On a users table that's nothing, and it's a legitimate trade if the team has been bitten before.
A roles table, when a user holds several
The moment a user can be both support and a billing admin, the column stops working and you want the join.
class User < ApplicationRecord
has_many :memberships
has_many :roles, through: :memberships
end
A single check is still cheap. user.roles.exists?(name: "admin") sends one query, and it's a
sensible one:
SELECT 1 AS one FROM "roles"
INNER JOIN "memberships" ON "roles"."id" = "memberships"."role_id"
WHERE "memberships"."user_id" = ? AND "roles"."name" = ? LIMIT 1
The cost shows up when you do it more than once, which is what every user list does. We loaded eight users and asked each one whether it was an admin:
| Design | Queries for 8 users |
|---|---|
| Role column | 1 |
Roles table, no includes |
9 |
Roles table, with includes(:roles) |
3 |
Nine is the shape everyone ships by accident: one query for the list and one per row. It's the plainest N+1 you'll ever write and it hides well, because a role check reads like a property access rather than a database call.
includes(:roles) takes it to three and that's the right answer, not a workaround. But notice
what it means: with the join design, every page that mentions a role has to remember to preload,
forever, and nothing in Rails reminds you. With the column design there's nothing to forget.
That's the real trade, and it isn't about one query versus three. It's about whether you want a performance characteristic you have to maintain on every new page.
A role isn't a permission
This is the distinction that decides whether you need a gem at all.
A role answers who is this person. Authorization answers may this person do this thing to this record, and the second question usually involves the record. "Is an admin" and "owns this project" and "belongs to the organisation that owns this project" are three different shapes, and only the first one is a role.
If every check in your app is the first shape, you don't need a policy layer and you shouldn't
install one. If you keep writing things like current_user.admin? || record.user == current_user
in views, that's the signal: the logic has outgrown the role and wants a home, and that home is
either a handful of plain methods on the model or a policy object per resource.
The failure mode to watch for is current_user.admin? appearing in forty templates. Each one is
a decision somebody made in a hurry, none of them are tested together, and changing the rule means
finding all forty. One method with a name that says what the rule is, called from those forty
places, costs nothing today and is the difference between a refactor and an archaeology project.
What this page doesn't cover
No gem was installed or benchmarked. The query counts above are the argument for and against each schema, and they're the same whether a policy layer sits on top or not, so a Pundit against CanCanCan comparison would be a different article with a different method.
Nothing here is about the admin interface itself, about scoping queries so a support user only sees their own organisation's rows, or about auditing who changed a role and when. That last one matters more than people expect at the point an auditor asks, and a role column with no history can't answer it.
The measurements came from SQLite in a test environment with eight rows. The query counts are structural and won't change, but nothing here says anything about how those queries behave against a users table with two million rows and an index that doesn't cover the role.
Comments
No comments yet. Be the first.