Policies & Permissions
Rhino uses Pundit to authorize every API action. Rather than writing authorization logic by hand for each resource, Rhino provides a base ResourcePolicy class that automatically checks permissions against the authenticated user's role. All you need to do is create a policy class that extends it.
How Policies Work
Every time a CRUD request hits a Rhino-generated endpoint, the corresponding policy method is invoked before the action executes. The flow looks like this:
- A request comes in (e.g.,
POST /api/posts). - Rhino resolves the policy for the
Postmodel (looks forPostPolicy). - The matching policy method is called (e.g.,
create?). - The base
ResourcePolicychecks whether the authenticated user has the required permission (e.g.,posts.store). - If the user has the permission, the action proceeds. If not, a
403 Forbiddenresponse is returned.
Policy resolution follows naming conventions. A Post model automatically resolves to PostPolicy. If no specific policy is found, Rhino falls back to Rhino::ResourcePolicy.
ResourcePolicy
ResourcePolicy is the base class that all Rhino policies extend. It provides default implementations for every CRUD action, each of which delegates to a permission check using the resource slug and action name.
Action to Policy Method to Permission mapping:
| API Action | Policy Method | Permission Checked |
|---|---|---|
GET /posts (index) | index? / view_any? | posts.index |
GET /posts/{id} (show) | show? / view? | posts.show |
POST /posts (store) | create? | posts.store |
PUT /posts/{id} (update) | update? | posts.update |
DELETE /posts/{id} (destroy) | destroy? / delete? | posts.destroy |
GET /posts/trashed | view_trashed? | posts.trashed |
POST /posts/{id}/restore | restore? | posts.restore |
DELETE /posts/{id}/force-delete | force_delete? | posts.forceDelete |
Each method in ResourcePolicy calls has_permission? on the authenticated user with the corresponding permission string. You never need to write this logic yourself unless you want to customize it.
Creating a Policy
A minimal policy requires no method implementations at all. The base class handles everything:
class PostPolicy < Rhino::ResourcePolicy
# The resource slug used for permission checks
# If not set, auto-resolved from Rhino.config
self.resource_slug = 'posts'
end
That is the entire policy. The ResourcePolicy parent class checks has_permission? automatically for every CRUD action. If the user has the matching permission, the action is allowed. If not, it is denied.
If your resource slug in Rhino.configure matches what would be auto-resolved, you can omit the resource_slug assignment entirely. It is only needed when you want to override the default.
Permission Format
Permissions follow a consistent dot-notation format:
{resource_slug}.{action}
Examples:
posts.index— can list postsposts.show— can view a single postposts.store— can create postsposts.update— can update postsposts.destroy— can delete postsposts.trashed— can view soft-deleted postsposts.restore— can restore soft-deleted postsposts.forceDelete— can permanently delete postsblogs.index— can list blogscomments.store— can create comments
Wildcard Permissions
Rhino supports wildcard permissions for broad access grants:
| Permission | Meaning |
|---|---|
* | Full access to everything (superadmin) |
posts.* | All actions on posts (read, write, delete, trash, restore, etc.) |
posts.index | Exact match — only the list action on posts |
Wildcards are checked hierarchically. When has_permission?('posts.store') is called, the system checks for an exact match first, then for posts.*, and finally for *.
The * wildcard grants unrestricted access to every resource and every action. Only assign it to fully trusted administrator roles.
How Permissions Are Stored
Rhino supports two permission sources, used depending on whether the request is organization-scoped or not:
User-level permissions (users.permissions)
For non-tenant route groups (e.g., :driver, :admin, :default), permissions are stored directly on the users table as a JSON column:
id | name | email | permissions (JSON)
1 | Alice Driver | alice@example.com | ["trips.index", "trips.show", "trucks.*"]
2 | Bob Admin | bob@example.com | ["*"]
3 | Carol User | carol@example.com | ["posts.index", "posts.show"]
This is the standard permission model and works for all apps, including non-multi-tenant apps.
Organization-scoped permissions (roles.permissions via user_roles)
For the :tenant route group, permissions are stored on the roles table and linked per-organization via the user_roles join table:
roles table:
id | name | slug | permissions (JSON)
1 | Admin | admin | ["*"]
2 | Editor | editor | ["posts.index", "posts.show", "posts.store", "posts.update", "comments.*"]
3 | Viewer | viewer | ["posts.index", "posts.show"]
user_roles table:
id | user_id | organization_id | role_id
1 | 1 | 1 | 1 (user 1 is admin in org 1)
2 | 2 | 1 | 2 (user 2 is editor in org 1)
3 | 1 | 2 | 2 (user 1 is editor in org 2)
This enables multi-tenant permission models where the same user can hold different roles in different organizations.
Resolution
When has_permission? is called:
- Organization present (tenant route group) → checks
roles.permissionsfor that organization viauser_roles - No organization (any other route group) → checks
users.permissionsdirectly
Complete Setup Examples
Non-Tenant App (User-Level Permissions)
For apps without multi-tenancy, assign permissions directly on the user:
# Assign permissions to users
admin.update!(permissions: ['*'])
editor.update!(permissions: [
'posts.index', 'posts.show', 'posts.store', 'posts.update',
'comments.*',
])
viewer.update!(permissions: [
'posts.index', 'posts.show',
'comments.index', 'comments.show',
])
Multi-Tenant App (Organization-Scoped Permissions)
For multi-tenant apps, create roles and assign users to organizations:
# 1. Create roles
admin = Role.create!(name: 'Admin', slug: 'admin', permissions: ['*'])
editor = Role.create!(name: 'Editor', slug: 'editor', permissions: [
'posts.index', 'posts.show', 'posts.store', 'posts.update', 'comments.*',
])
# 2. Assign user to organization with role
UserRole.create!(
user_id: user.id,
organization_id: organization.id,
role_id: admin.id
)
Hybrid App (Both)
For hybrid apps with tenant and non-tenant route groups, use both:
# User-level permissions for non-tenant routes (e.g., driver app)
driver.update!(permissions: ['trips.index', 'trips.show', 'trucks.*'])
# Organization-scoped permissions for tenant routes
UserRole.create!(
user_id: user.id,
organization_id: organization.id,
role_id: admin.id
)
What Each Role Can Do
Here is a breakdown of what each role is authorized to perform on the posts resource:
| Action | Admin | Editor | Viewer |
|---|---|---|---|
| List posts | Yes | Yes | Yes |
| View post | Yes | Yes | Yes |
| Create post | Yes | Yes | No |
| Update post | Yes | Yes | No |
| Delete post | Yes | No | No |
| View trashed | Yes | No | No |
| Restore | Yes | No | No |
| Force delete | Yes | No | No |
The Admin role has *, so every action is allowed. The Editor role has explicit posts.index, posts.show, posts.store, and posts.update permissions, so they can read and write but not delete. The Viewer role only has posts.index and posts.show, restricting them to read-only access.
Attribute Permissions
Beyond action-level authorization, policies control which fields a user can read and write. This gives you fine-grained control over field visibility and writability on a per-role basis.
Field Visibility (Read)
Two methods control which fields are included in API responses:
permitted_attributes_for_show(user)— a whitelist of fields the user can see. Return['*']to allow all fields.hidden_attributes_for_show(user)— a blacklist of fields to hide. This is merged with the whitelist, so fields listed here are always removed from responses.
class UserPolicy < Rhino::ResourcePolicy
self.resource_slug = 'users'
def permitted_attributes_for_show(user)
if has_role?(user, 'admin')
['*'] # Admins see everything
else
['id', 'name', 'avatar'] # Others see limited fields
end
end
def hidden_attributes_for_show(user)
if has_role?(user, 'admin')
[]
else
['stripe_id', 'internal_notes'] # Always hidden for non-admins
end
end
end
When permitted_attributes_for_show returns a specific list (not ['*']), all columns not in the list are automatically hidden from API responses. The hidden_attributes_for_show blacklist is then merged on top, ensuring those fields are removed even if they appear in the whitelist.
The same two methods also gate querying: a hidden attribute cannot be used as a ?filter[] or a ?sort, and ?search= skips it. See attribute permissions apply to queries too.
Both methods receive nil when there is no authenticated user. Always handle the nil case.
Scope Permissions
Control which named scopes a user may select with ?scope=:
class RoutePolicy < Rhino::ResourcePolicy
def permitted_scopes(user)
return ["*"] if has_role?(user, "dispatcher")
["available_for_drivers"]
end
end
The model's rhino_scopes says which scopes exist on the wire; this says which of them this user may pick. The effective set is the intersection, so the policy can only narrow the model's declaration. Names are the underscored form.
The default is ["*"]. A scope denied here returns the same 403 message as one that does not exist, so the endpoint never reveals which scopes a model has. The model's rhino_default_scope is applied by the server when the client sends no scope at all, so it is not subject to this list; requesting it by name is.
Field Permissions (Write)
Two methods control which fields a user can submit on create and update requests:
permitted_attributes_for_create(user)— fields the user can submit when creating a resource.permitted_attributes_for_update(user)— fields the user can submit when updating a resource.
Return ['*'] to allow all fields, or return a specific list to restrict access.
class PostPolicy < Rhino::ResourcePolicy
self.resource_slug = 'posts'
def permitted_attributes_for_create(user)
if has_role?(user, 'admin')
['*'] # Admins can set any field
else
['title', 'content'] # Others can only set title and content
end
end
def permitted_attributes_for_update(user)
if has_role?(user, 'admin')
['*']
else
['title', 'content']
end
end
end
When a user submits fields they are not permitted to set, the API returns a 403 Forbidden response that explicitly names the forbidden fields:
{
"message": "You are not allowed to set the following field(s): status, is_published"
}
Forbidden fields are explicitly rejected with a 403 response, not silently ignored. This makes it clear to the client exactly which fields caused the authorization failure.
Using has_role? Helper
The has_role?(user, role_slug) method is available in all policies that extend Rhino::ResourcePolicy. It checks whether the given user holds the specified role in the current organization context.
class PostPolicy < Rhino::ResourcePolicy
self.resource_slug = 'posts'
def permitted_attributes_for_create(user)
if has_role?(user, 'admin')
['*']
elsif has_role?(user, 'editor')
['title', 'content', 'excerpt', 'category_id']
else
['title', 'content']
end
end
def permitted_attributes_for_update(user)
if has_role?(user, 'admin')
['*']
elsif has_role?(user, 'editor')
['title', 'content', 'excerpt', 'category_id']
else
['title', 'content']
end
end
end
Use has_role? in any attribute permission method to branch logic based on the user's role. The helper handles nil users gracefully, returning false when no user is authenticated.
Include Authorization
When a request uses the ?include query parameter to eager-load relationships, Rhino performs an additional authorization check. It verifies that the authenticated user has index permission on the included resource before loading it.
Example request:
GET /api/posts?include=comments
Rhino checks whether the user has comments.index permission. If the user does not have that permission, the request is rejected with a 403 Forbidden:
{
"message": "You do not have permission to include comments."
}
This means that even if a user has full access to posts, they cannot eager-load relationships they are not authorized to view. Each included resource is independently authorized.
This applies to all includes, including nested ones. A request like ?include=comments.author checks permissions on both comments and the author resource.
Organization-Scoped Permissions
In multi-tenant applications, permissions are evaluated per organization. A user can hold different roles in different organizations, and permission checks respect this context.
# User is admin in Org A
user.has_permission?('posts.store', org_a) # true
# Same user is viewer in Org B
user.has_permission?('posts.store', org_b) # false
The organization context is automatically resolved from the current request in Rhino's middleware. When a user makes an API call scoped to a specific organization, the permission check uses the role assigned to that user within that organization.
This means you do not need to manually pass the organization when using policies through Rhino's API endpoints. The organization is resolved from the request context (typically via a URL segment or subdomain). The explicit organization parameter is only needed when calling has_permission? directly in your own code.
Custom Policy Methods
While the base ResourcePolicy handles most cases, you can override any policy method to add custom authorization logic. A common pattern is restricting users to only modify their own records:
class PostPolicy < Rhino::ResourcePolicy
self.resource_slug = 'posts'
# Only allow users to update their own posts (unless admin)
def update?
if user.has_permission?('*')
true
else
super && record.user_id == user.id
end
end
end
In this example, the update? method first checks if the user is a superadmin (has the * permission). If not, it calls the parent method to verify the user has posts.update permission and checks that the post belongs to the user. Both conditions must be true for the update to proceed.
You can apply this pattern to any policy method:
# Only allow viewing unpublished posts if user is the author
def show?
return false unless super
if !record.is_published && record.user_id != user.id
return false
end
true
end
# Only allow deletion within 24 hours of creation
def destroy?
if user.has_permission?('*')
return true
end
super && record.created_at > 24.hours.ago
end
Always call super in your overrides to preserve the base permission check. Skipping the parent call means the permission system is bypassed for that action.
Error Responses
When authorization fails for any reason — missing permission, failed custom logic, or unauthenticated access — Rhino returns a standard error response:
{
"message": "This action is unauthorized."
}
HTTP status: 403 Forbidden
If the user is not authenticated at all and the route requires authentication, the auth middleware returns:
{
"message": "Unauthenticated."
}
HTTP status: 401 Unauthorized
Layered Permissions
By default Rhino resolves a user's effective permissions from three layers, so you don't have to copy a full permission set onto every user.
effective = (role ∪ granted) − denied # deny always wins
| Layer | Where it lives | Purpose |
|---|---|---|
| role | org_role_permissions(organization_id, role_id, permissions) | The shared set every user with that role in that org inherits. Defined once per (org, role). |
| granted | user_roles.granted_permissions | Extra abilities for one user (additive). |
| denied | user_roles.denied_permissions | Abilities removed from one user (subtractive). Deny wins over everything, even a role *. |
| legacy | user_roles.permissions | The pre-4.3 per-user list, still honored as an allow layer. |
The pre-existing global roles.permissions column is also still honored, as a fallback consulted only when the layers above are empty — so existing apps keep working unchanged. Wildcards (*, posts.*) work on every layer.
Why this exists
Previously, storing partial permissions on user_roles.permissions shadowed the role entirely, so teams stored the full set on every user and invented a new role for every exception. With the role layer:
- Add a table → grant
widgets.*on the role layer once; every member inherits it. - Give one user extra access → add to their
granted_permissions. - Take one ability from one user → add it to their
denied_permissions— no new role needed.
Examples
# Org-wide role layer: every "editor" in org 1 can manage posts and read comments.
OrgRolePermission.create!(
organization: org, role: editor_role,
permissions: ["posts.*", "comments.index"]
)
# This editor also gets comment moderation, but cannot delete posts.
UserRole.create!(
user: user, role: editor_role, organization: org,
granted_permissions: ["comments.destroy"],
denied_permissions: ["posts.destroy"]
)
user.has_permission?("posts.update", org) # => true (role layer)
user.has_permission?("comments.destroy", org) # => true (granted)
user.has_permission?("posts.destroy", org) # => false (denied wins)
Use explain_permission to see which layer decided:
user.explain_permission("posts.destroy", org)
# => { granted: false, reason: "denied" }
Resolution rules
- If the permission matches
denied_permissions→ DENY (deny always wins). - Else if it matches
role ∪ granted ∪ legacy→ ALLOW. - Else, fall back to the global
roles.permissionsonly when the layers above are empty. - Otherwise → DENY (default-deny).
Deny is intentionally deny-overrides, not "most-specific-wins": a denied permission stays denied even under a role *.
Migrating an existing app
To lift your existing per-user permissions into the role layer, run:
rake rhino:permissions_migrate # dry-run preview
rake rhino:permissions_migrate APPLY=1 # write the changes
For each (organization, role) group it computes the common subset of every user's permissions, writes it to org_role_permissions, and reduces each user row to just its delta. Effective permissions are preserved exactly. It is idempotent and skips groups that already have a role layer.