Skip to main content

Policies & Permissions

Rhino uses Laravel's built-in policy system 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:

  1. A request comes in (e.g., POST /api/posts).
  2. Laravel auto-resolves the policy for the Post model using its standard naming convention (PostPolicy).
  3. The matching policy method is called (e.g., create()).
  4. The base ResourcePolicy checks whether the authenticated user has the required permission (e.g., posts.store).
  5. If the user has the permission, the action proceeds. If not, a 403 Forbidden response is returned.
info

Policy resolution follows Laravel's conventions. A Post model automatically resolves to App\Policies\PostPolicy. You do not need to register policies manually as long as you follow this convention.

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 ActionPolicy MethodPermission Checked
GET /posts (index)viewAny()posts.index
GET /posts/{id} (show)view()posts.show
POST /posts (store)create()posts.store
PUT /posts/{id} (update)update()posts.update
DELETE /posts/{id} (destroy)delete()posts.destroy
GET /posts/trashedviewTrashed()posts.trashed
POST /posts/{id}/restorerestore()posts.restore
DELETE /posts/{id}/force-deleteforceDelete()posts.forceDelete

Each method in ResourcePolicy calls hasPermission() 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:

app/Policies/PostPolicy.php
<?php

namespace App\Policies;

use Rhino\Policies\ResourcePolicy;

class PostPolicy extends ResourcePolicy
{
// The resource slug used for permission checks
// If not set, auto-resolved from config/rhino.php
protected $resourceSlug = 'posts';
}

That is the entire policy. The ResourcePolicy parent class checks hasPermission() automatically for every CRUD action. If the user has the matching permission, the action is allowed. If not, it is denied.

tip

If your resource slug in config/rhino.php matches what would be auto-resolved (the plural, lowercase, kebab-case form of the model name), you can omit the $resourceSlug property 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 posts
  • posts.show — can view a single post
  • posts.store — can create posts
  • posts.update — can update posts
  • posts.destroy — can delete posts
  • posts.trashed — can view soft-deleted posts
  • posts.restore — can restore soft-deleted posts
  • posts.forceDelete — can permanently delete posts
  • blogs.index — can list blogs
  • comments.store — can create comments

Wildcard Permissions​

Rhino supports wildcard permissions for broad access grants:

PermissionMeaning
*Full access to everything (superadmin)
posts.*All actions on posts (read, write, delete, trash, restore, etc.)
posts.indexExact match — only the list action on posts

Wildcards are checked hierarchically. When hasPermission('posts.store') is called, the system checks for an exact match first, then for posts.*, and finally for *.

warning

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 (user_roles.permissions)​

For the tenant route group, permissions are stored per-organization via the user_roles pivot table:

id | user_id | organization_id | role_id | permissions (JSON)
1 | 1 | 1 | 1 | ["*"]
2 | 2 | 1 | 2 | ["posts.index", "posts.show", "posts.store"]
3 | 1 | 2 | 2 | ["posts.index", "posts.show"]

This enables multi-tenant permission models where the same user can hold different roles and permissions in different organizations.

Resolution​

When hasPermission() is called:

  1. Organization present (tenant route group) → checks user_roles.permissions for that organization
  2. No organization (any other route group) → checks users.permissions directly

Complete Setup Examples​

Non-Tenant App (User-Level Permissions)​

For apps without multi-tenancy, assign permissions directly on the user:

database/seeders/UserSeeder.php
// 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 with per-org permissions:

database/seeders/RoleSeeder.php
// 1. Create roles
$admin = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$editor = Role::create(['name' => 'Editor', 'slug' => 'editor']);

// 2. Assign user to organization with permissions
UserRole::create([
'user_id' => $user->id,
'organization_id' => $organization->id,
'role_id' => $admin->id,
'permissions' => ['*'],
]);

Hybrid App (Both)​

For hybrid apps with tenant and non-tenant route groups, use both:

database/seeders/UserSeeder.php
// 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,
'permissions' => ['*'],
]);

3. What Each Role Can Do​

Here is a breakdown of what each role is authorized to perform on the posts resource:

ActionAdminEditorViewer
List postsYesYesYes
View postYesYesYes
Create postYesYesNo
Update postYesYesNo
Delete postYesNoNo
View trashedYesNoNo
RestoreYesNoNo
Force deleteYesNoNo

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 (can this user create/update/delete?), policies control which fields a user can read and write. This is handled through the HasPermittedAttributes contract, which ResourcePolicy implements by default.

Field Visibility (Read)​

Control which columns are visible in API responses using two complementary methods:

MethodPurpose
permittedAttributesForShow()Whitelist — only these fields are visible. Use ['*'] to allow all.
hiddenAttributesForShow()Blacklist — these fields are always hidden, even if permitted.
app/Policies/UserPolicy.php
<?php

namespace App\Policies;

use Illuminate\Contracts\Auth\Authenticatable;
use Rhino\Policies\ResourcePolicy;

class UserPolicy extends ResourcePolicy
{
protected $resourceSlug = 'users';

public function permittedAttributesForShow(?Authenticatable $user): array
{
if ($user?->hasRole('admin')) {
return ['*']; // Admins see everything
}

return ['id', 'name', 'avatar']; // Others see limited fields
}

public function hiddenAttributesForShow(?Authenticatable $user): array
{
if ($user?->hasRole('admin')) {
return [];
}

return ['stripe_id', 'internal_notes']; // Always hidden for non-admins
}
}

When permittedAttributesForShow() returns a specific list (not ['*']), all columns not in that list are automatically hidden. The hiddenAttributesForShow() results are merged on top, so fields can be hidden via either method.

On the model side, the HidableColumns trait calls these methods when serializing. The hidden fields are stripped from every API response automatically.

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.

info

Both methods receive null when there is no authenticated user (e.g., public endpoints). Always handle the null case.

Scope Permissions​

Control which named scopes a user may select with ?scope=:

app/Policies/RoutePolicy.php
public function permittedScopes(?Authenticatable $user): array
{
if ($user?->hasRole('dispatcher')) {
return ['*']; // Every scope the model declares
}

return ['availableForDrivers'];
}

The model's $allowedScopes 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.

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 $defaultScope 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)​

Control which fields a user can submit on store (create) and update actions:

MethodPurpose
permittedAttributesForCreate()Fields the user can set when creating a resource
permittedAttributesForUpdate()Fields the user can set when updating a resource
app/Policies/PostPolicy.php
class PostPolicy extends ResourcePolicy
{
protected $resourceSlug = 'posts';

public function permittedAttributesForCreate(?Authenticatable $user): array
{
if ($user?->hasRole('admin')) {
return ['*']; // Admins can set any field
}

return ['title', 'content']; // Editors can only set title and content
}

public function permittedAttributesForUpdate(?Authenticatable $user): array
{
if ($user?->hasRole('admin')) {
return ['*'];
}

return ['title', 'content'];
}
}

When a user submits fields they are not permitted to set, the API returns a 403 Forbidden with a clear error message:

Response
{
"message": "You are not allowed to set the following field(s): status, is_published"
}
info

Forbidden fields are explicitly rejected with a 403 response, making it clear to API consumers which fields they cannot set.

Default Behavior​

By default, ResourcePolicy returns ['*'] for all permitted attribute methods and [] for hidden attributes. This means all fields are allowed unless you explicitly restrict them in your policy.

Using hasRole() Helper​

ResourcePolicy provides a hasRole() helper that checks the user's role within the current organization context:

app/Policies/PostPolicy.php
class PostPolicy extends ResourcePolicy
{
public function permittedAttributesForCreate(?Authenticatable $user): array
{
// hasRole checks the user's role in the current request's organization
if ($this->hasRole($user, 'admin')) {
return ['*'];
}

if ($this->hasRole($user, 'editor')) {
return ['title', 'content', 'category_id'];
}

return ['title', 'content'];
}
}

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

Response
{
"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.

warning

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.

app/Models/User.php
// User is admin in Org A
$user->hasPermission('posts.store', $orgA); // true

// Same user is viewer in Org B
$user->hasPermission('posts.store', $orgB); // 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.

tip

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 header or URL segment). The explicit $organization parameter is only needed when calling hasPermission() 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:

app/Policies/PostPolicy.php
<?php

namespace App\Policies;

use Illuminate\Contracts\Auth\Authenticatable;
use Rhino\Policies\ResourcePolicy;

class PostPolicy extends ResourcePolicy
{
protected $resourceSlug = 'posts';

// Only allow users to update their own posts (unless admin)
public function update(?Authenticatable $user, $post): bool
{
if ($user->hasPermission('*')) {
return true;
}

return parent::update($user, $post) && $post->user_id === $user->id;
}
}

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:

app/Policies/PostPolicy.php
// Only allow viewing unpublished posts if user is the author
public function view(?Authenticatable $user, $post): bool
{
if (!parent::view($user, $post)) {
return false;
}

if (!$post->is_published && $post->user_id !== $user->id) {
return false;
}

return true;
}

// Only allow deletion within 24 hours of creation
public function delete(?Authenticatable $user, $post): bool
{
if ($user->hasPermission('*')) {
return true;
}

return parent::delete($user, $post)
&& $post->created_at->diffInHours(now()) < 24;
}
tip

Always call parent::methodName() 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:

Response
{
"message": "This action is unauthorized."
}

HTTP status: 403 Forbidden

If the user is not authenticated at all and the route requires authentication, Laravel's auth middleware returns:

Response
{
"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
LayerWhere it livesPurpose
roleorg_role_permissions(organization_id, role_id, permissions)The shared set every user with that role in that org inherits. Defined once per (org, role).
granteduser_roles.granted_permissionsExtra abilities for one user (additive).
denieduser_roles.denied_permissionsAbilities removed from one user (subtractive). Deny wins over everything, even a role *.
legacyuser_roles.permissionsThe pre-4.3 per-user list. Still honored as an allow layer, so existing apps keep working unchanged.

Wildcards (*, posts.*) work on every layer.

Why this exists​

Previously the only org-scoped source was user_roles.permissions, so teams stored the full permission set on every user. Adding a table meant updating every user row, and a one-off exception meant inventing a new role. 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_id' => 1,
'role_id' => $editorRole->id,
'permissions' => ['posts.*', 'comments.index'],
]);

// This editor also gets comment moderation, but cannot delete posts.
UserRole::create([
'user_id' => $user->id,
'role_id' => $editorRole->id,
'organization_id' => 1,
'granted_permissions' => ['comments.destroy'],
'denied_permissions' => ['posts.destroy'],
]);

$user->hasPermission('posts.update', $org); // true (role layer)
$user->hasPermission('comments.destroy', $org);// true (granted)
$user->hasPermission('posts.destroy', $org); // false (denied wins)

Use explainPermission() to see which layer decided:

$user->explainPermission('posts.destroy', $org);
// ['granted' => false, 'reason' => 'denied']

Resolution rules​

  1. If the permission matches denied_permissions → DENY (deny always wins).
  2. Else if it matches role ∪ granted ∪ legacy → ALLOW.
  3. Else → DENY (default-deny).

Deny is intentionally deny-overrides, not "most-specific-wins": a denied permission stays denied even under a role *.

Backward compatibility​

Layered permissions are fully backward-compatible. With org_role_permissions empty and the delta columns unset, resolution reduces to exactly the pre-4.3 behavior (legacy user_roles.permissions). The rhino:install command scaffolds the new table, model, and columns.

Migrating an existing app​

To lift your existing per-user permissions into the role layer, run:

php artisan rhino:permissions-migrate          # dry-run preview
php artisan rhino:permissions-migrate --apply # 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 (granted_permissions). Effective permissions are preserved exactly. It is idempotent and skips groups that already have a role layer.