Scoping
So far, we’ve been authorizing access to individual records—can this user view this ticket? Can they edit it? But there’s a different question we haven’t addressed: which records should a user see in the first place?
Right now, the tickets index page shows every ticket to every user. A customer sees tickets created by other customers (go to /tickets and check yourself). An agent sees tickets they have nothing to do with. That’s not how a real help desk works.
We also have a visibility problem with comments: Bob’s internal note on the billing ticket—“Confirmed duplicate charge. Refund initiated.”—is visible to Alice (a customer). Internal comments should be hidden from customers entirely.
Action Policy solves this with scoping—defining how to filter collections based on the current user.
Step 1: Add a relation scope to TicketPolicy
Open . Add a relation_scope block at the top of the class, before the rule methods:
class TicketPolicy < ApplicationPolicy relation_scope do |relation| next relation if user.admin? next relation.where(agent_id: [user.id, nil]) if user.agent?
relation.where(user_id: user.id) end
def show? true end
def manage? record.user_id == user.id || (user.agent? && record.agent_id == user.id) end
def destroy? = falseendrelation_scope defines how to filter an ActiveRecord relation for the current user:
- Customers see only their own tickets (
where(user_id: user.id)) - Agents see tickets assigned to them plus unassigned tickets (
where(agent_id: [user.id, nil])) - Admins see everything
Step 2: Use authorized_scope in the controller
Open . Update the index action to use authorized_scope:
def index @tickets = Ticket.includes(:user, :agent).order(created_at: :desc) @tickets = authorized_scope(Ticket.all).includes(:user, :agent).order(created_at: :desc) endauthorized_scope(Ticket.all) looks up TicketPolicy, finds its relation_scope, and applies it to the given relation. The result is a filtered query—only the tickets the current user is allowed to see.
Step 3: Scope comments to hide internal notes
Now let’s fix the internal comments problem. Open and add both a relation_scope and a show? rule:
class CommentPolicy < ApplicationPolicy authorize :ticket, optional: true
relation_scope do |relation| next relation unless user.customer?
relation.where(internal: false) end
def show? !record.internal? || user.agent? end
def create? user.agent? || ticket&.open? || ticket&.in_progress? end
def destroy? record.user_id == user.id endendTwo layers of protection here:
relation_scopefilters at the query level—internal comments are excluded from the SQL query for customers, so they never reach the viewshow?provides direct-access protection—if someone tries to access an internal comment directly (e.g., via a future API), the rule blocks it
Step 4: Scope comments in the controller
Update the show action in to scope comments:
def show @comments = @ticket.comments.includes(:user).order(:created_at) @comments = authorized_scope(@ticket.comments).includes(:user).order(:created_at) @comment = Comment.new endauthorized_scope(@ticket.comments) applies CommentPolicy’s relation_scope to the ticket’s comments association. For customers, internal comments are filtered out before they ever reach the template.
Try it out
Go to the Tickets page. You’re logged in as Alice (a customer)—you should see only her three tickets. Dana’s and Eve’s tickets are not visible.
Now open the Billing ticket. Bob’s internal comment (“Confirmed duplicate charge. Refund initiated.”) should be hidden.
Sign in as Bob (agent) and check the tickets index—you should see more tickets (assigned to Bob plus unassigned ones). Open the billing ticket and the internal comment should be visible.
Verify with tests
Run the tests:
$ bin/rails testWhat changed
| Concept | What it does |
|---|---|
relation_scope { |relation| ... } | Defines how to filter ActiveRecord relations per user |
authorized_scope(relation) | Applies the matching scope in the controller |
show? on CommentPolicy | Direct-access protection for internal comments |
| Pre-check + scoping | Admin pre-check means scoping is skipped—admins see everything |
- Preparing Ruby runtime
- Prepare development database