Search by

bspdx / keystone

jvjvjv

Multi-tenant role-based access control (RBAC) for Laravel — bring your own authentication

v1.0.0-rc.1 2026-10-25 16:46 UTC

This package is auto-updated.

Last update: 2026-10-25 16:48:23 UTC


README

Latest Version on Packagist Total Downloads License

A multi-tenant role-based access control (RBAC) package for Laravel. Keystone adds roles, permissions, and tenant-scoped authorization to whatever User model your application already has — it does not provide authentication. Bring your own login, registration, 2FA, and passkeys (Fortify, Breeze, or anything else); Keystone plugs in via a single trait.

  • 👥 Role-Based Access Control (RBAC) - Clean service layer API
  • 🏢 Multi-Tenancy Ready - Optional, global-scope tenant isolation; one schema in both modes, so you can switch it on or off without a migration
  • 🚪 Laravel Gate Integration - Permissions registered via Gate::before()
  • 🌐 API-First Design - JSON role/permission management API included, with no-escalation rules built in
  • 🔌 Framework-Agnostic Auth - Works alongside any authentication stack

Table of Contents

Requirements

  • PHP 8.2+
  • Laravel 12.41+ or 13.x
  • MySQL 5.7+ / PostgreSQL 9.6+ / SQLite 3.8.8+

Installation

Step 1: Install via Composer

composer require bspdx/keystone

Step 2: Publish Configuration & Migrations

php artisan vendor:publish --tag=keystone-config --tag=keystone-migrations

# Optional: example RBAC API routes
php artisan vendor:publish --tag=keystone-routes

# Optional: baseline roles/permissions seeder, plus a demo-user seeder
php artisan vendor:publish --tag=keystone-seeders

Step 3: Run Migrations

php artisan migrate

This creates tables for roles and permissions (custom RBAC) and adds a nullable tenant_id column to your users table. The schema is the same whether KEYSTONE_MULTI_TENANT is on or off: the roles, permissions, and assignment tables always carry tenant_id, and in single-tenant mode it stays NULL. You can turn multi-tenancy on or off later without a migration (see Multi-Tenancy).

Step 4: Seed Demo Data (Optional)

php artisan db:seed --class=KeystoneSeeder

This creates 4 default roles (super-admin, admin, editor, user) with common permissions. For local development you can also create one demo user per role (password password). That seeder refuses to run in production:

php artisan db:seed --class=KeystoneDemoUserSeeder

Step 5: Set Up Your Own Authentication

Keystone does not configure or require any authentication package. Install and configure laravel/fortify (or Breeze, or your own solution) exactly as you would in a Keystone-free Laravel app. Keystone only needs your User model to implement KeystoneUser and use the HasKeystone trait (see below) — it has no opinion on how users log in.

Configuration

The package configuration is located at config/keystone.php.

Feature Flags

'features' => [
    // Enable multi-tenant mode (scopes roles, permissions, and assignments by tenant_id)
    'multi_tenant' => env('KEYSTONE_MULTI_TENANT', false),
],

The tenant_id columns exist in both modes. When multi_tenant is enabled, Keystone writes and filters by them, using global scopes for automatic tenant isolation (not Spatie's teams feature). When it's disabled, Keystone ignores them.

Key Features:

  • Automatic Filtering - Authenticated users only see roles/permissions for their tenant
  • Global Roles/Permissions - Set tenant_id = NULL for cross-tenant access
  • UUID Support - tenant_id is always a UUID column
  • Cross-Tenant Queries - Use ::withoutTenant() for cross-tenant operations

See Multi-Tenancy Documentation for detailed architecture, usage examples, and migration guides.

RBAC Settings

'rbac' => [
    // Cache expiration time for roles and permissions (in seconds)
    'cache_expiration' => 60 * 60 * 24, // 24 hours

    // Super admin role that bypasses all permission checks
    // (in multi-tenant mode it must be a global role; the name is reserved in tenants)
    'super_admin_role' => 'super-admin',
],

Guards

Whenever you don't pass a guard, Keystone uses your application's default guard (auth.defaults.guard) — when creating roles and permissions, checking them, and resolving names. Pass a guard explicitly ($user->hasPermissionTo('edit-posts', 'api')) to use another.

Usage

Adding RBAC to Your User Model

Add the KeystoneUser interface and the HasKeystone trait to your existing User model — this is Keystone's only integration point. The trait implements every method the interface declares and adds role/permission relationships and tenant-aware scoping; it does not touch authentication.

<?php

namespace App\Models;

use BSPDX\Keystone\Contracts\KeystoneUser;
use BSPDX\Keystone\Traits\HasKeystone;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable implements KeystoneUser
{
    use Notifiable, HasKeystone;

    // Add whatever your own auth stack requires, e.g.:
    // use Laravel\Fortify\TwoFactorAuthenticatable;
    // use Laravel\Sanctum\HasApiTokens;

    // ... rest of your model
}

Keystone resolves your user model from keystone.user.model, then auth.providers.users.model, and throws a clear LogicException if neither is set or the model doesn't implement KeystoneUser. Any other model (for example an Admin model on another guard) can hold roles the same way by declaring implements KeystoneUser and use HasKeystone;.

You can also query users by assigned role directly from the model:

use App\Models\User;

$admins = User::role('admin')->get();
$staff = User::role(['admin', 'manager'])->get();

Service Layer

All role and permission operations go through dedicated services, abstracted behind interfaces.

Using Services in Controllers

<?php

namespace App\Http\Controllers;

use BSPDX\Keystone\Services\Contracts\RoleServiceInterface;
use BSPDX\Keystone\Services\Contracts\PermissionServiceInterface;
use BSPDX\Keystone\Services\Contracts\AuthorizationServiceInterface;

class AdminController extends Controller
{
    public function __construct(
        private RoleServiceInterface $roleService,
        private PermissionServiceInterface $permissionService,
        private AuthorizationServiceInterface $authService
    ) {}

    public function assignRole(User $user)
    {
        // Get all roles
        $roles = $this->roleService->getAllWithPermissions();

        // Add roles to user (keeps existing roles)
        $this->authService->assignRolesToUser($user, ['admin', 'editor']);

        // Or replace the user's roles with exactly this set
        $this->authService->syncRolesForUser($user, ['editor']);

        // Check if user has role
        if ($this->authService->userHasRole($user, 'admin')) {
            // User is admin
        }
    }
}

Benefits:

  • Clean dependency injection
  • Easy to mock for testing
  • No direct external package dependencies in your code

Routes

Keystone doesn't auto-register routes. Add them manually from the published examples:

Alternatively, set keystone.load_routes to true to have Keystone load the management API routes itself (unless you've published routes/keystone-api.php). The web routes are examples only and are never loaded automatically.

Web Routes (routes/keystone-web.php):

// Include in your routes/web.php
require __DIR__.'/keystone-web.php';

API Routes (routes/keystone-api.php):

// Include in your routes/api.php
require __DIR__.'/keystone-api.php';

Middleware

Keystone registers these middleware aliases:

Alias Purpose
keystone.role:<role> Require one of the listed roles (OR logic)
keystone.permission:<perm> Require one of the listed permissions (OR logic)
role:<role> Same as keystone.role — registered only if your app hasn't defined a role alias
permission:<perm> Same as keystone.permission — registered only if your app hasn't defined a permission alias

Super-admins pass both. Unauthenticated requests raise Laravel's AuthenticationException, so your app's own guest handling applies (401 for JSON, redirect for browsers). An authenticated user whose model doesn't implement KeystoneUser (for example another guard's model) gets 403.

Role Middleware

Route::middleware(['auth', 'role:admin'])->group(function () {
    // Only users with 'admin' role can access
});

// Multiple roles (OR logic)
Route::middleware(['auth', 'role:admin,editor'])->group(function () {
    // Users with 'admin' OR 'editor' role can access
});

Permission Middleware

Route::middleware(['auth', 'permission:edit-posts'])->group(function () {
    // Only users with 'edit-posts' permission
});

// Multiple permissions
Route::middleware(['auth', 'permission:edit-posts,publish-posts'])->group(function () {
    // Users with either permission can access
});

Checking Permissions in Code

Traditional Approach (User Model Methods)

// Check role
if (auth()->user()->hasRole('admin')) {
    // User is an admin
}

// Check permission
if (auth()->user()->can('edit-posts')) {
    // User can edit posts
}

// Check multiple roles
if (auth()->user()->hasAnyRole(['admin', 'editor'])) {
    // User has at least one of these roles
}

// Super admin check
if (auth()->user()->isSuperAdmin()) {
    // User is super admin (bypasses all permission checks)
}

Super-admins and literal checks: super-admins pass every permission check (hasPermissionTo(), can(), the permission: middleware) and the role: middleware, but hasRole() / hasAnyRole() / hasAllRoles() and hasDirectPermission() are literal. A super-admin only "has" the roles and direct permissions actually assigned to them. Use isSuperAdmin() or a permission check when you mean "may do anything".

Gate integration: Keystone adds one Gate::before callback, in every context (HTTP, queue workers, the scheduler, tinker). It returns true for super-admins and for permissions the user holds, and otherwise returns null — so your policies and Gate::define abilities still decide. Keystone never denies on the Gate's behalf, and users whose model doesn't implement KeystoneUser are left to your policies. The permission list is read from the cache store at most once per request or job (Cache::memo()).

Service Layer Approach (Recommended for Controllers)

use BSPDX\Keystone\Services\Contracts\AuthorizationServiceInterface;

class PostController extends Controller
{
    public function __construct(
        private AuthorizationServiceInterface $authService
    ) {}

    public function edit(Post $post)
    {
        if ($this->authService->userHasPermission(auth()->user(), 'edit-posts')) {
            // User can edit posts
        }
    }
}

API Usage

Keystone's role/permission management API is JSON-first, making it easy to drive from any frontend. Protect the API routes with whatever auth guard your application uses:

curl -X GET http://localhost/api/roles \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"

Add a Role to a User (keeps the user's other roles):

curl -X POST http://localhost/api/users/1/roles \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"roles": ["admin"]}'

Replace a User's Roles (PUT; send [] to remove all):

curl -X PUT http://localhost/api/users/1/roles \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"roles": ["editor"]}'

The same POST adds / PUT replaces pattern applies to /api/users/{user}/permissions and /api/roles/{role}/permissions.

Response shapes. Every role in a response is {id, name, guard_name, title, description, permissions, created_at}, where permissions is an array of names, and every permission is {id, name, guard_name, title, description, created_at}. created_at is ISO 8601 (or null). GET /api/roles adds users_count to each role, and GET /api/permissions adds roles (names). In multi-tenant mode, a caller with a tenant only sees names and counts from their own tenant and global rows.

Listing. GET /api/roles and GET /api/permissions are ordered by name. Pass per_page (1–100) and optionally page to get one page plus a meta object (current_page, per_page, last_page, total); without per_page the whole list is returned.

Limits. roles / permissions arrays accept at most 100 distinct names (422 otherwise). The example route group applies throttle:60,1; adjust it to your needs.

Errors. Keystone's exceptions render as JSON client errors: 409 for a duplicate role or permission, 422 for the reserved super-admin name, 403 for deleting the super-admin role. They aren't reported to your error log. Any other exception is left to your app's handler.

API Security Rules

  • No privilege escalation. A caller who isn't a super-admin can only grant a role if they hold every permission it grants, and only grant (or add to a role) permissions they hold. Only a super-admin can grant, remove, or modify the super-admin role. Violations return 403 and change nothing. The trait, services, and console commands are not restricted.
  • Global rows are read-only for tenants. In multi-tenant mode, a caller who belongs to a tenant gets 403 when deleting or changing the permissions of a global role or permission.
  • Tenant-aware names. Names must be unique within the caller's tenant (and guard), and names sent to assignment endpoints must exist in the target's tenant (otherwise 422).

Architecture

Service Layer

All role and permission operations go through dedicated services. A null guard always means your default guard.

Every method that takes a user takes a BSPDX\Keystone\Contracts\KeystoneUser.

  • RoleService - Role CRUD and queries
    • getAllWithPermissions() (ordered by name), paginateWithPermissions($perPage, ?$page), create($name, ?$guard), delete($role), syncPermissions($role, $permissions), getUserRoles(KeystoneUser $user), findByName($name, ?$guard, ?$tenantId) (a tenant row wins over a global one), getAllForTenant(?$tenantId), assignToUser(KeystoneUser $user, $roles), removeFromUser(KeystoneUser $user, $roles), clearCache()
  • PermissionService - Permission CRUD and queries
    • getAllWithRoles() (ordered by name), paginateWithRoles($perPage, ?$page), create($name, ?$guard), delete($permission), syncToUser(KeystoneUser $user, $permissions), getUserPermissions(KeystoneUser $user), getAllUserPermissions(KeystoneUser $user), findByName(), getAllForTenant(), assignToUser(), removeFromUser(), assignToRole(), removeFromRole()
  • AuthorizationService - High-level authorization operations
    • assignRolesToUser(), assignPermissionsToUser() (add), syncRolesForUser(), syncPermissionsForUser() (replace), userHasRole(), userHasAnyRole(), userHasAllRoles(), userHasPermission(), userHasAnyPermission(), userHasAllPermissions(), userHasDirectPermission(), userCanBypassPermissions() — all with the super-admin bypass applied

All services are registered in Laravel's service container with interface bindings and convenient aliases:

  • keystone.roles
  • keystone.permissions
  • keystone.authorization
  • keystone.cache

User Contract

BSPDX\Keystone\Contracts\KeystoneUser declares every public method HasKeystone adds (roles(), permissions(), the assignment methods, the role and permission checks, getAllPermissions(), isSuperAdmin(), canBypassPermissions()). Keystone decides whether a model takes part in RBAC with instanceof KeystoneUser.

Models

  • BSPDX\Keystone\Models\KeystoneRole - Custom role model with tenant scoping and isSuperAdmin()
  • BSPDX\Keystone\Models\KeystonePermission - Custom permission model with tenant scoping

Public methods on the trait and both models are typed: role arguments are string|int|KeystoneRole|iterable, permission arguments string|int|KeystonePermission|iterable, mutating methods return static, and checks return bool. Names, integer IDs, models, arrays, and collections are all accepted; any other type is a TypeError. syncRoles() and syncPermissions() on a user resolve every name first and replace the assignments in one transaction, so a typo changes nothing. Deleting a user removes their assignments (soft-deleted users keep them until force-deleted).

HTTP Layer

The example routes are served by six controllers in BSPDX\Keystone\Http\Controllers, each using only resource action names:

Controller Actions Routes
RoleController index, store, destroy GET/POST /api/roles, DELETE /api/roles/{role}
PermissionController index, store, destroy GET/POST /api/permissions, DELETE /api/permissions/{permission}
UserRoleController store (add), update (replace) POST/PUT /api/users/{user}/roles
UserPermissionController store (add), update (replace) POST/PUT /api/users/{user}/permissions
RolePermissionController store (add), update (replace) POST/PUT /api/roles/{role}/permissions
UserAccessController show GET /api/users/{user}/roles-permissions

Roles and permissions in responses are built by Http\Resources\RoleResource and Http\Resources\PermissionResource.

Exceptions

All extend BSPDX\Keystone\Exceptions\KeystoneException, which renders as a JSON response with a message and isn't reported (ShouldntReport):

  • CannotDeleteSuperAdminRole - deleting the super-admin role (403)
  • ReservedRoleName - using the super-admin name for a tenant role, multi-tenant (422)
  • RoleAlreadyExists / PermissionAlreadyExists - a duplicate name and guard in the same tenant, or among global rows (409)

Benefits

  • Testability - Mock service interfaces in tests instead of facades
  • Maintainability - RBAC logic isolated in a dedicated service layer
  • Backend-Only - No UI to maintain or theme; drop it into any frontend

Multi-Tenancy

Keystone provides comprehensive multi-tenant support using global scopes for automatic tenant isolation. Roles and permissions can be global (accessible across all tenants) or tenant-specific (isolated per organization).

Keystone handles the RBAC side of multi-tenancy — scoping roles, permissions, and assignments to a tenant_id. It does not provide a Tenant model, tenant creation, or user-to-tenant assignment. Your application is responsible for managing tenants and populating tenant_id on your User model. Keystone reads that value automatically to scope all role and permission queries.

Quick Start

Enable multi-tenancy in your .env:

KEYSTONE_MULTI_TENANT=true

No migration is needed: the tenant columns exist in both modes. On an existing install, run the one-time data step to move existing assignments into their users' tenants (roles and permissions stay global):

php artisan keystone:enable-multi-tenancy

To go back to single-tenant, run php artisan keystone:disable-multi-tenancy (it refuses when two tenants share a role or permission name), then set KEYSTONE_MULTI_TENANT=false.

Features

  • Automatic Tenant Filtering - Global scopes automatically filter roles/permissions by authenticated user's tenant
  • Global Roles/Permissions - Set tenant_id = NULL to make roles/permissions accessible across all tenants
  • Tenant-Specific Roles - Roles with tenant_id are isolated to a single organization
  • UUID Support - tenant_id is always a UUID column
  • Cross-Tenant Queries - Use ::withoutTenant() scope for cross-tenant operations
  • Global Super-Admin - Only a global role can be the super-admin role; its name is reserved in tenants

Usage Examples

Creating Global Roles

use BSPDX\Keystone\Models\KeystoneRole;

// Create a global role accessible to all tenants
$superAdmin = KeystoneRole::withoutTenant()->create([
    'name' => 'super_administrator',
    'title' => 'Super Administrator',
    'tenant_id' => null,  // Global role
]);

Creating Tenant-Specific Roles

// tenant_id is auto-populated from authenticated user
Auth::login($userInTenantA);

$manager = KeystoneRole::create([
    'name' => 'department_manager',
    'title' => 'Department Manager',
    // tenant_id automatically set from auth()->user()->tenant_id
]);

Tenant Query Scopes

Both KeystoneRole and KeystonePermission provide the same scopes. The automatic tenant scope always applies, so a tenant user already sees their tenant's rows plus global rows. These scopes narrow that further. They never widen it.

KeystoneRole::global()->get();                            // global only (tenant_id = NULL)
KeystoneRole::tenantSpecific()->get();                    // tenant-owned only (tenant_id NOT NULL)
KeystoneRole::forTenant($tenantId)->get();                // that tenant's rows only — no globals
KeystoneRole::withoutTenant()->forTenant($otherId)->get(); // explicit cross-tenant read

Super-Admin Operations

// View all roles across all tenants
$allRoles = KeystoneRole::withoutTenant()->get();

// Check if user can bypass tenant filtering
if ($user->canBypassPermissions()) {
    // User is super-admin
}

Keystone Management Commands

# Create roles and permissions (--tenant creates them in a tenant; global when omitted)
php artisan keystone:make-role manager --permissions=view-posts,edit-posts
php artisan keystone:make-permission edit-posts
php artisan keystone:make-role manager --tenant=019c17d6-1a87-71be-a6a4-718da52579e9

# Assign and remove roles (user by ID or email)
php artisan keystone:assign-role jane@example.com admin
php artisan keystone:assign-role 42 editor --sync
php artisan keystone:unassign-role 42 admin

# Assign and remove permissions (--tenant picks which tenant's role to target)
php artisan keystone:assign-permission edit-posts --to-role=editor
php artisan keystone:assign-permission edit-posts --to-user=42
php artisan keystone:unassign-permission edit-posts --from-role=editor --tenant=019c17d6-1a87-71be-a6a4-718da52579e9

# Switch an existing install's data into or out of tenants
php artisan keystone:enable-multi-tenancy
php artisan keystone:disable-multi-tenancy

Learn More

For comprehensive documentation on multi-tenancy:

Known Issues

Role and permission list queries follow the logged-in user. In multi-tenant mode the automatic tenant scope on KeystoneRole / KeystonePermission reads auth()->user(). In a request from a tenant user, KeystoneRole::all() returns that tenant's roles plus global ones; in a queued job, a scheduled command, tinker, or for a user with no tenant, the same query returns every tenant's rows.

  • In code that runs without a logged-in user, name the tenant explicitly: KeystoneRole::withoutTenant()->forTenant($tenantId) (add ->orWhereNull('tenant_id') inside a where() for global rows), or the services' getAllForTenant($tenantId).
  • If you don't want the automatic filter at all, call withoutTenant() on the query, for example once in a query method of your own.
  • What a user or role holds is not affected: hasRole(), hasPermissionTo(), can(), and the relationships never depend on who is logged in.

Details, plus a note on duplicates under concurrent writes, are in docs/multi-tenancy.md.

Testing

Run the package tests:

composer test                # multi-tenant suite
composer test-single-tenant  # single-tenant suite

Or with PHPUnit directly:

./vendor/bin/phpunit

Security

See API Security Rules. If you discover any security issues, please email info@bspdx.com instead of using the issue tracker.

Credits

Note: As of v0.10.0, Keystone is a multi-tenant RBAC package only — it does not provide authentication, and has no runtime dependency on Fortify, Sanctum, or any passkey package. Bring your own authentication and add the HasKeystone trait to your User model.

License

The MIT License (MIT). Please see License File for more information.

Support