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:

app/policies/ticket_policy.rb
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? = false
end

relation_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:

app/controllers/tickets_controller.rb
def index
@tickets = Ticket.includes(:user, :agent).order(created_at: :desc)
@tickets = authorized_scope(Ticket.all).includes(:user, :agent).order(created_at: :desc)
end

authorized_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:

app/policies/comment_policy.rb
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
end
end

Two layers of protection here:

  • relation_scope filters at the query level—internal comments are excluded from the SQL query for customers, so they never reach the view
  • show? 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:

app/controllers/tickets_controller.rb
def show
@comments = @ticket.comments.includes(:user).order(:created_at)
@comments = authorized_scope(@ticket.comments).includes(:user).order(:created_at)
@comment = Comment.new
end

authorized_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:

Terminal window
$ bin/rails test

What changed

ConceptWhat 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 CommentPolicyDirect-access protection for internal comments
Pre-check + scopingAdmin pre-check means scoping is skipped—admins see everything
Powered by WebContainers
Files
Preparing Environment
  • Preparing Ruby runtime
  • Prepare development database