yamanalali/laravel-scoped-rbac

Multi-tenant scoped RBAC (privileges, functional roles, data scopes, delegations, SoD) for Laravel

Maintainers

Package info

github.com/yamanalali/laravel-scoped-rbac

pkg:composer/yamanalali/laravel-scoped-rbac

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

dev-main 2026-04-24 19:09 UTC

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 users table with an id column (Laravel’s default users migration satisfies this).
  • An organizations table with an id column. Foreign keys use organizations(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.php into the scoped-rbac config key.
  • Loads package migrations from database/migrations.
  • Registers RbacRequestContext (singleton), RbacAccessService (singleton), and ScopeSummarizerMinimalScopeSummarizer by default.
  • Subscribes to Eloquent saved / deleted on 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 an RbacActor with a non-null organisation (403 otherwise).
  • assign and all admin* methods: same as above, plus isOrgAdmin() must be true for that user (org derived from getRbacOrganisationId()).

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 body organisation_data_scope_id (nullable); validates and calls setActiveScope.
  • 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