Record-level permissions decide whether a user may see or change one specific record, based on that record's data: who created it, which team it belongs to, what state it is in. Two support agents with the same role can both open the tickets screen, yet each sees only the tickets for their own team. The role says "you may work with tickets". The record-level rule says "but only these ones".
Three levels of permission
Access rules usually sit at one of three levels. Most apps need all three, and mixing them up is where bugs come from.
- Object-level (or model-level) permissions apply to a whole type of record. "Support can read invoices." "Only finance can create refunds." They never look at a particular invoice. This is where role-based access control does most of its work.
- Record-level permissions apply to individual rows. "You can edit a project if you own it." "You can read a ticket if you are on its team." "Nobody can edit an invoice once it is paid." The answer changes from row to row.
- Field-level permissions apply to individual attributes of a record. "Everyone on the team can read the project, but only finance can see or change its budget."
The order matters: first the type, then the row, then the column.
The models in this example
A project belongs to a team and has an owner. People join teams through memberships.
# app/models/project.rb
class Project < ApplicationRecord
belongs_to :team
belongs_to :owner, class_name: "User"
end
# app/models/membership.rb
class Membership < ApplicationRecord
belongs_to :team
belongs_to :user
end
# app/models/user.rb
class User < ApplicationRecord
has_many :memberships
has_many :teams, through: :memberships
end
The role enum on User (viewer, support, finance, admin) is the same one set up in the RBAC entry. Roles answer the object-level question. The associations above carry what the record-level rules need.
A policy method that reads the record
In Pundit, a policy method receives the user and the record, so record-level rules are ordinary Ruby that looks at record.
# app/policies/project_policy.rb
class ProjectPolicy < ApplicationPolicy
def show?
user.admin? || member?
end
def update?
return false if record.archived?
user.admin? || owner? || (member? && user.support?)
end
def destroy?
user.admin? || owner?
end
private
def owner?
record.owner_id == user.id
end
def member?
user.memberships.exists?(team_id: record.team_id)
end
end
owner? is ownership: the user's ID is stored on the row. member? is relationship: the row points at a team and the user belongs to it. archived? is state: the same row is editable today and locked tomorrow. Those three cover most record-level rules you will write.
Keep the helpers private and named after the relationship. When a rule changes, from "team members" to "team members and their managers", it changes in one method.
A scope that filters the list
A policy method checks one record you have already loaded. An index page needs the question asked in SQL, before anything loads. That is the policy's Scope.
# app/policies/project_policy.rb
class ProjectPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
return scope.all if user.admin?
team_ids = user.memberships.select(:team_id)
scope.where(team_id: team_ids).or(scope.where(owner: user))
end
end
end
user.memberships.select(:team_id) becomes a subquery, so the database does the filtering in one statement and you can keep chaining order, includes or pagination. The Scope in Pundit's generated ApplicationPolicy raises NoMethodError until you define resolve, so a model without a scope fails loudly rather than listing everything.
The scope and show? must agree. If the list shows a project that show? refuses, users click into errors. If show? allows a project the list hides, the list is quietly wrong. A spec that compares them catches drift:
# spec/policies/project_policy_spec.rb
RSpec.describe ProjectPolicy do
it "lists exactly the projects show? allows" do
user = create(:user, role: :support)
create(:membership, user: user, team: create(:team))
create_list(:project, 5)
visible = ProjectPolicy::Scope.new(user, Project).resolve.to_a
Project.find_each do |project|
expect(ProjectPolicy.new(user, project).show?).to eq(visible.include?(project))
end
end
end
404 or 403
When a user asks for a record they may not touch, the response code tells them something. Pick it on purpose.
-
404 Not Found when the user may not know the record exists. Someone guessing
/projects/4812should not learn that project 4812 is real. - 403 Forbidden when the user can already see the record but may not do this action to it. A team member who can read a project but not delete it gets an honest "no".
Loading through the scope gives you both:
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
rescue_from Pundit::NotAuthorizedError do
head :forbidden
end
def show
@project = authorize policy_scope(Project).find(params[:id])
end
def destroy
@project = policy_scope(Project).find(params[:id])
authorize @project
@project.destroy!
redirect_to projects_path
end
end
find on the scoped relation raises ActiveRecord::RecordNotFound for a project outside the scope, and Rails turns that into a 404 by default. A project inside the scope reaches authorize, and a denied destroy? becomes a 403 through rescue_from. Calling Project.find without the scope skips the first step, and every denial turns into a 403 that confirms the ID is valid.
Which rule wins when levels conflict
Deny by default, and require every level to say yes.
- A missing rule is a no. Pundit's generated
ApplicationPolicyreturnsfalsefrom every action method, so a new action stays closed until someone writes its rule. - The levels stack. A user needs the object-level permission, then the record-level one, then the field-level one for each attribute they change. A yes at one level never overrides a no at another.
- Exceptions are written out, not implied. If admins may edit archived projects, say so in the method, before the check that would refuse them.
Here is a stricter version of update? that makes the order visible:
def update?
return true if user.admin? # explicit exception
return false unless user.support? # object level: the role
return false if record.archived? # record level: the state
owner? || member? # record level: the relationship
end
Reading top to bottom, each line can only narrow access, except the exceptions you put first and can see in review. Field-level rules then run on the attributes of a request that already passed this method.
Record-level permissions and Postgres row-level security
PostgreSQL has its own version of this idea: row-level security. After ALTER TABLE ... ENABLE ROW LEVEL SECURITY, every normal query on the table must be allowed by a policy, and if the table has no policy, no rows are visible. Superusers and roles with BYPASSRLS skip it, and table owners do too unless the table is set to FORCE ROW LEVEL SECURITY. Policies match against database roles and session settings rather than your current_user object, so Rails apps that adopt it, often for multitenancy, usually keep their application-level policies as well.
Record-level permissions in an Avo app
When your team runs its back office on Avo, the Authorization add-on applies the same Pundit policies there. show?, update? and destroy? are checked against each record, and the policy's Scope filters which records reach your resource Index view, the Show and Edit views, and has-many association panels. With explicit authorization on, a resource without a policy, or an action whose policy method is missing, is denied, which matches the deny-by-default rule above.
Frequently asked questions
What are record-level permissions?
Record-level permissions decide access to an individual record based on its data, such as its owner, its team or its status. They let two users with the same role see or change different rows of the same table. In Rails they usually live in policy methods and query scopes.
What is the difference between object-level and record-level permissions?
Object-level permissions apply to a whole type of record, such as "support can read invoices". Record-level permissions apply to specific rows, such as "support can read invoices for their own region". Most apps check the object-level rule first and the record-level rule second.
Which permission wins when rules conflict?
The safest convention is deny by default: a missing rule means no, and the user needs a yes at every level that applies. Any exception, such as admins editing locked records, should be written explicitly in the policy so it is visible in code review.
How do I limit which records a user sees in Rails?
Filter the query, not the rendered list. With Pundit, define a Scope class with a resolve method that returns only the rows the user may see, and load records through policy_scope in every action, including show and update. Records outside the scope then never load, and a direct link to one returns a 404.
Should I return 404 or 403 for a record a user cannot access?
Return 404 when the user should not learn the record exists, and 403 when they can already see it but may not perform the action. Loading records through a policy scope before authorizing gives you this split without extra code.