yamanalali / laravel-scoped-rbac
Multi-tenant scoped RBAC (privileges, functional roles, data scopes, delegations, SoD) for Laravel
Requires
- php: ^8.2
- illuminate/auth: ^12.0
- illuminate/cache: ^12.0
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/http: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
- illuminate/validation: ^12.0
This package is auto-updated.
Last update: 2026-04-25 16:50:44 UTC
README
A Laravel package for organisation-scoped access control: a privilege catalog, functional roles, organisation data scopes, session / request scope context, privilege delegations, segregation-of-duties (SoD) rules, and access audit logging. It targets multi-tenant apps where users have an organisation_id (or equivalent) and effective permissions can depend on an active data scope (grant, office, organisation-wide, etc.) as well as a privilege code.
Requirements
- PHP 8.2+
- Laravel 12 (this package requires
illuminate/*^12.0)
Database prerequisites
Migrations assume:
- A
userstable with anidcolumn (Laravel’s defaultusersmigration satisfies this). - An
organizationstable with anidcolumn. Foreign keys useorganizations(id). If your tenant table has another name, add your own migration (view, synonym, or bridge table), or adjust the package migrations in a fork before running them.
Install
From Packagist
When the package is registered on Packagist:
composer require yamanalali/laravel-scoped-rbac
From a local path (development)
In your app’s composer.json:
{
"repositories": [
{
"type": "path",
"url": "../laravel-scoped-rbac",
"options": { "symlink": true }
}
],
"require": {
"yamanalali/laravel-scoped-rbac": "@dev"
}
}
Then run composer update yamanalali/laravel-scoped-rbac.
What the service provider registers
ScopedRbac\ScopedRbacServiceProvider is auto-discovered. It:
- Merges
config/scoped-rbac.phpinto thescoped-rbacconfig key. - Loads package migrations from
database/migrations. - Registers
RbacRequestContext(singleton),RbacAccessService(singleton), andScopeSummarizer→MinimalScopeSummarizerby default. - Subscribes to Eloquent
saved/deletedon role assignment and role–privilege pivot models to bump RBAC cache version keys.
Publish config if you want a copy under config/:
php artisan vendor:publish --tag=scoped-rbac-config
Run migrations:
php artisan migrate
If your app already defines the same table names, remove or reconcile duplicate migrations so tables are not created twice.
Environment variables
Values map to config/scoped-rbac.php (see that file for defaults).
| Variable | Config key | Purpose |
|---|---|---|
RBAC_ENABLED |
enabled |
When false (default), RbacAccessService::can() does not run the assignment/delegation pipeline; it only uses the legacy path (see below). Middleware that checks config('scoped-rbac.enabled') becomes a no-op. |
RBAC_FALLBACK_TO_ORG_ROLES |
fallback_to_org_roles |
When RBAC is enabled and true (default), users who fail functional-role checks may still pass via legacyCan(): a fixed map of privilege codes to Gate::forUser($user)->allows(...) on rbacWorkspace(), hasOrgRole(), isOrgAdmin(), etc. Not a generic “any Gate” pass. |
RBAC_ORG_ADMIN_BYPASS |
org_admin_bypass |
When RBAC is enabled and true (default), isOrgAdmin() can grant access after assignments/delegations/fallback fail; bypass is written to the audit log. |
RBAC_CACHE_TTL |
cache_ttl_seconds |
Seconds for can() result caching. Default 120. |
RBAC_CONTEXT_HEADER |
context_header |
Request header for scope id (default X-Rbac-Context). |
RBAC_SCOPES_HEADER |
scopes_response_header |
Response header for JSON scope list (default X-Rbac-Scopes). |
RBAC_ACTIVE_SCOPE_HEADER |
active_scope_response_header |
Response header for active scope id (default X-Rbac-Active-Scope). |
RBAC_ACTOR_MODEL |
actor_model |
FQCN of the user model; must implement ScopedRbac\Contracts\RbacActor. Used when resolving users in HTTP APIs (e.g. assign). Default App\Models\User. |
RBAC_ORGANIZATION_MODEL |
organization_model |
FQCN of the tenant row; its primary key must match organisation_id on users. Default App\Models\Organization. |
RBAC_REFERENCE_GRANT_MODEL |
reference_models.grant |
Optional; for validating grant-backed data scopes in RbacController. |
RBAC_REFERENCE_DEPARTMENT_MODEL |
reference_models.department |
Optional; department scopes. |
RBAC_REFERENCE_WORK_CALENDAR_MODEL |
reference_models.work_calendar |
Optional; region scopes. |
RBAC_REFERENCE_OFFICE_MODEL |
reference_models.office |
Optional; office scopes. |
Reference model classes, when configured, must be Eloquent models with an organisation_id column (see ReferenceModelGuard).
1. Implement RbacActor on your user model
The authenticated user must implement ScopedRbac\Contracts\RbacActor (Authenticatable + Authorizable plus organisation helpers). Minimal example:
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Model; use ScopedRbac\Contracts\RbacActor; class User extends Authenticatable implements RbacActor { public function getRbacOrganisationId(): ?int { return $this->organisation_id !== null ? (int) $this->organisation_id : null; } public function rbacWorkspace(): ?Model { return $this->organization; } public function isOrgAdmin(?int $organisationId = null): bool { // Your logic; used for admin APIs and org_admin_bypass when enabled } public function hasOrgRole(string $roleKey, ?int $organisationId = null): bool { // Used by legacyCan() when RBAC_FALLBACK_TO_ORG_ROLES is true } }
If you use RBAC_FALLBACK_TO_ORG_ROLES, define Laravel Gate abilities such as manageHrEmployees and manageSettings where your privilege codes map to them (see RbacAccessService::legacyCan() for which codes call which abilities).
2. Privilege codes
Database rows use string code values. Canonical constants live in ScopedRbac\Support\RbacPrivilege. Package migrations seed and expand the privileges table using those constants.
3. Scope summaries (ScopeSummarizer)
ScopeSummarizer turns an OrganisationDataScope into a display string (API payloads, optional response headers). Default binding: MinimalScopeSummarizer (scope type + id/code).
Override in AppServiceProvider::register() (after the package boots):
use ScopedRbac\Contracts\ScopeSummarizer; use App\Services\YourDataScopeSummarizer; $this->app->bind(ScopeSummarizer::class, YourDataScopeSummarizer::class);
4. Middleware
| Class | Behaviour |
|---|---|
ScopedRbac\Http\Middleware\ResolveRbacRequestContext |
If RBAC is enabled: for an authenticated RbacActor with an organisation, reads RBAC_CONTEXT_HEADER as a data-scope id (when present and valid), else falls back to user_rbac_session_contexts.active_organisation_data_scope_id, and writes the resolved id into RbacRequestContext (and persists session context when the header sets a scope). |
ScopedRbac\Http\Middleware\AppendRbacScopeHeaders |
If RBAC is enabled: after the request, sets RBAC_SCOPES_HEADER (JSON from activeScopesWithPrivileges) and RBAC_ACTIVE_SCOPE_HEADER when applicable. |
Example registration (Laravel 12–style bootstrap/app.php; adjust to your app’s imports and groups):
<?php use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'rbac.context' => \ScopedRbac\Http\Middleware\ResolveRbacRequestContext::class, 'rbac.headers' => \ScopedRbac\Http\Middleware\AppendRbacScopeHeaders::class, ]); $middleware->api(prepend: [ \ScopedRbac\Http\Middleware\ResolveRbacRequestContext::class, ]); $middleware->api(append: [ \ScopedRbac\Http\Middleware\AppendRbacScopeHeaders::class, ]); }) ->create();
Keep your real app’s other Application::configure chains (withRouting, withExceptions, etc.); the snippet above only shows the middleware fragment. Use web / api / append / prepend according to where your authenticated organisation users live.
5. Authorisation in application code
RbacAccessService::can()
use ScopedRbac\Services\RbacAccessService; $rbac = app(RbacAccessService::class); // Signature: can(RbacActor $user, string $privilegeCode, ?int $organisationDataScopeId = null): bool if ($rbac->can($user, 'procurement.purchase_request.read')) { // allowed }
When RBAC_ENABLED is false: can() returns only the result of legacyCan() (no assignment / delegation / SoD path).
When RBAC_ENABLED is true: evaluation uses functional-role assignments (and delegations), then optionally legacyCan() if RBAC_FALLBACK_TO_ORG_ROLES is true, then optional org_admin_bypass. Results are cached per org/user/scope/privilege with invalidation on relevant model changes.
Active data scope for can(): the optional third argument organisationDataScopeId forces that scope as context. If omitted or null, the service resolves context in this order: that argument is skipped → RbacRequestContext::activeOrganisationDataScopeId (set by middleware) → persisted UserRbacSessionContext. Assignments are matched against that context via scopeMatches() (e.g. organisation-wide scope matches any context; grant/office/country rules are more specific—see RbacAccessService).
AssertsRbacCatalogPrivilege trait
use ScopedRbac\Http\Concerns\AssertsRbacCatalogPrivilege; $this->assertRbacCatalogPrivilege($request, 'your.privilege.code');
When RBAC is disabled, this is a no-op (does not abort). When enabled, it aborts 403 if the user is not an RbacActor or fails can() for the given code.
6. Optional HTTP API (RbacController)
ScopedRbac\Http\Controllers\RbacController returns JSON. The package does not register routes—you declare URIs and HTTP verbs in your app (e.g. routes/api.php) behind auth (session, Sanctum, etc.).
Authentication expectations:
myScopes,setSessionScope: authenticated user must be anRbacActorwith a non-null organisation (403otherwise).assignand alladmin*methods: same as above, plusisOrgAdmin()must be true for that user (org derived fromgetRbacOrganisationId()).
Controller methods (map to your own paths and verbs):
myScopes— list scopes + privilege codes for the current user; includes active scope id from session row.setSessionScope— JSON bodyorganisation_data_scope_id(nullable); validates and callssetActiveScope.assign— org admin assigns role + data scope to another user in the same org; enforces SoD rules when RBAC is enabled.adminCatalog,adminFunctionalRolesIndex,adminStoreFunctionalRole,adminUpdateFunctionalRole,adminDestroyFunctionalRole,adminAttachPrivilege,adminDetachPrivilege,adminDataScopesIndex,adminStoreDataScope,adminDestroyDataScope,adminAssignmentsIndex,adminUpdateAssignment,adminDestroyAssignment.
Example route wiring (verbs are yours to choose; match JSON bodies to Request::validate in the controller):
use Illuminate\Support\Facades\Route; use ScopedRbac\Http\Controllers\RbacController; Route::middleware(['auth:sanctum'])->prefix('api')->group(function () { Route::get('/rbac/scopes', [RbacController::class, 'myScopes']); Route::post('/rbac/session-scope', [RbacController::class, 'setSessionScope']); Route::post('/rbac/admin/assign', [RbacController::class, 'assign']); Route::get('/rbac/admin/catalog', [RbacController::class, 'adminCatalog']); // …remaining admin* routes });
Package layout
| Path | Contents |
|---|---|
src/ |
ScopedRbac\ — models, RbacAccessService, RbacRequestContext, middleware, RbacController, support enums/constants |
config/scoped-rbac.php |
Feature flags, actor/organization models, reference models, header names, cache TTL |
database/migrations/ |
Schema + privilege seeds (imports ScopedRbac\Support\* constants) |
License
MIT