Namespaced Policies

So far, all our policies live flat in . But real applications often have multiple contexts — a public storefront, an admin dashboard, an API — each with different authorization rules for the same resources.

Namespaced policies let you define separate policy classes per context, all scoped to a Ruby module.

The Scenario

We’re adding an admin dashboard at /admin/products. Admins need a different view: they can see everything, including drafts, and the product list has management actions.

The challenge: Admin::ProductsController authorizes the same Product model, but needs stricter rules (admin-only) and a different scope (all products, not just published).

How Namespace Lookup Works

When authorize! runs inside Admin::ProductsController, Action Policy doesn’t just look for ProductPolicy. It first checks for a namespaced policy matching the controller’s module:

Admin::ProductsController authorizing Product
→ looks for Admin::ProductPolicy
→ if not found, falls back to ProductPolicy

This cascade is automatic — no configuration needed. The namespace is inferred from the controller’s module (Admin), and Action Policy checks Admin::ProductPolicy before the top-level ProductPolicy.

Setting Up the Admin Namespace

The routes are already set up with an admin namespace. Open to see it:

namespace :admin do
resources :products, only: [:index]
end

This generates the /admin/products path and routes to Admin::ProductsController#index.

The Admin Controller

Open . The controller is already scaffolded:

module Admin
class ProductsController < ApplicationController
def index
@products = Product.all
authorize!
end
end
end

Right now it loads all products and calls authorize!. The authorize! call with no arguments uses the current action name (index) and infers the policy from the controller — which means it will look for Admin::ProductPolicy first.

The Starter Admin Policy

Open . It’s currently a skeleton:

module Admin
class ProductPolicy < ApplicationPolicy
# Admin area: only admins can access
end
end

It inherits from ApplicationPolicy, which already has the allow_admins pre-check. But we need to add an explicit index? rule and a scope.

Adding the Admin Policy Rules

Update :

module Admin
class ProductPolicy < ApplicationPolicy
def index?
user.admin?
end
relation_scope do |relation|
relation.all # Admins see everything in admin area
end
end
end

The index? rule is explicit: only admins. The relation_scope returns all products — no filtering by published status, because admin needs to manage drafts too.

Using authorized_scope in the Admin Controller

Update to use authorized_scope:

module Admin
class ProductsController < ApplicationController
def index
@products = authorized_scope(Product.all)
authorize!
end
end
end

Now the controller gets its product list through the policy scope. Since Action Policy finds Admin::ProductPolicy for this controller, it uses the relation_scope defined there — returning all products regardless of published status.

Demo: Admin vs Regular User

Start the server and visit /admin/products.

  • As admin (admin@example.com): you see all products including drafts, and the page loads successfully.
  • As a regular user (user@example.com): you’re redirected with an “unauthorized” flash because index? returns false for non-admins.
  • Logged out: same redirect.

The Fallback in Action

Here’s something interesting: if you delete (or rename) , Action Policy falls back to ProductPolicy. Try commenting out the file contents to see what happens:

Without Admin::ProductPolicy, the admin controller would use the public ProductPolicy. Regular users could access /admin/products (because ProductPolicy#index? returns true), and they’d only see published products (because ProductPolicy’s scope filters drafts).

This fallback is useful during development — you can add namespaced policies incrementally. But it can also mask authorization bugs if you’re not careful.

Disabling the Fallback with strict_namespace

To prevent the cascade and require an explicit namespaced policy, use strict_namespace:

module Admin
class ProductsController < ApplicationController
# Require Admin::ProductPolicy to exist — no fallback
self.authorization_strict_namespace = true
def index
@products = authorized_scope(Product.all)
authorize!
end
end
end

With strict_namespace enabled, if Admin::ProductPolicy doesn’t exist, Action Policy raises a ActionPolicy::NotFound error rather than falling back silently.

Custom Namespace with authorization_namespace

Sometimes the namespace you want doesn’t match the controller’s module name. You can override it by defining authorization_namespace in the controller:

module Admin
class ProductsController < ApplicationController
# Use "SuperAdmin" namespace instead of "Admin"
def authorization_namespace
"SuperAdmin"
end
def index
@products = authorized_scope(Product.all)
authorize!
end
end
end

This tells Action Policy to look for SuperAdmin::ProductPolicy instead of Admin::ProductPolicy. Useful when you have a deeper hierarchy or want to share policies across multiple admin modules.

You can also return nil from authorization_namespace to skip namespace lookup entirely and use the top-level policy:

def authorization_namespace
nil # Always use ProductPolicy, skip Admin:: lookup
end

Organizing Your Policy Namespace

As your application grows, you might have multiple namespaced policies. A common layout:

app/policies/
application_policy.rb ← Base class
product_policy.rb ← Public storefront rules
admin/
application_policy.rb ← Admin base class (optional)
product_policy.rb ← Admin area rules
api/
v1/
product_policy.rb ← API v1 rules

If you create a namespace-specific base class (Admin::ApplicationPolicy), you can add shared behavior like verifying user.admin? once:

app/policies/admin/application_policy.rb
module Admin
class ApplicationPolicy < ::ApplicationPolicy
pre_check :require_admin
private
def require_admin
deny! unless user&.admin?
end
end
end

Then all admin policies inherit from Admin::ApplicationPolicy instead of ::ApplicationPolicy.

Testing Namespaced Policies

You can test Admin::ProductPolicy just like any other policy:

Terminal window
$ bin/rails console
store(dev)> admin = User.find_by(email_address: "admin@example.com")
store(dev)> policy = Admin::ProductPolicy.new(nil, user: admin)
store(dev)> policy.index?
=> true
store(dev)> regular = User.find_by(email_address: "user@example.com")
store(dev)> policy = Admin::ProductPolicy.new(nil, user: regular)
store(dev)> policy.index?
=> false
store(dev)> # Test the scope
store(dev)> policy = Admin::ProductPolicy.new(nil, user: admin)
store(dev)> policy.apply_scope(Product.all, type: :relation).count
=> 5 # All products, including drafts

Notice that when testing the admin policy directly, you instantiate Admin::ProductPolicy explicitly — the namespace lookup only happens automatically inside controllers.

What We Built

  • Admin::ProductPolicy — admin-only authorization with an unrestricted scope
  • Admin::ProductsController — uses authorized_scope to get the admin-filtered list
  • The namespace cascade in action: automatic lookup, fallback, and strict mode
  • authorization_namespace for custom namespace logic

Next, we’ll look at failure reasons — understanding precisely why authorization failed and surfacing that information to users.

Powered by WebContainers
Files
Preparing Environment
  • Preparing Ruby runtime
  • Prepare development database
  • Starting Rails server