LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 2 views

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.

Eight users rendered in a list, with the query count for each of three role designs. The column design answers every check from the row it already loaded and sends one query in total. The join table design without includes sends nine, one for the user list and one per user. Adding includes brings it to three, the user list plus two preload queries. Underneath, the enum storage is shown as integers, with admin stored as 2, and an arrow shows a new role inserted at the front shifting every value by one so existing admin rows read as support.

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.

#rails #active-record #authorization

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.