Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

Laravel RBAC for Multi-Organization Portals

A portal that serves many organizations is not “one app with a role column.” It is a matrix: user × organization × permission, plus the hard rule that a token for Org A must never read Org B’s rows even when the user bel

Laravel RBAC for Multi-Organization Portals

A portal that serves many organizations is not “one app with a role column.” It is a matrix: user × organization × permission, plus the hard rule that a token for Org A must never read Org B’s rows even when the user belongs to both. Most IDOR bugs I hunt in Laravel portals come from checking a global role and forgetting the organization scope on the query.

I build Loop Origin, where we ship multi-tenant Laravel systems for dealers, partners, and internal ops portals. I wrote this with help from AI tools and checked every command and claim myself. Below is the RBAC shape I use when one login can administer several organizations without collapsing authorization into a single boolean.

How should Laravel RBAC work for multi-organization portals?

Direct answer: Model memberships as user-organization links with roles scoped to that organization, authorize with policies that take the org context (not only the user), and force every sensitive query through where organization_id = currentOrg(). Global roles are for platform staff only; treating “admin” as global across orgs is how IDOR starts.

The data model: membership before role

Avoid users.role = 'admin'. Prefer an explicit membership:

Schema::create('organizations', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('name');
    $table->timestampsTz();
});

Schema::create('organization_user', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('organization_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
    $table->string('role', 64); // owner, admin, billing, viewer
    $table->timestampsTz();
    $table->unique(['organization_id', 'user_id']);
    $table->index(['user_id', 'organization_id']);
});

Schema::create('permissions', function (Blueprint $table) {
    $table->string('name')->primary(); // invoices.refund, members.invite
});

Schema::create('organization_role_permission', function (Blueprint $table) {
    $table->string('role', 64);
    $table->string('permission');
    $table->primary(['role', 'permission']);
});

Permissions are stable strings. Roles are bundles per product. Organization-specific custom roles are possible later; start with a fixed set so policies stay readable.

Resolve “current organization” once per request (subdomain, path prefix, or explicit header) and store it on the request—never infer it only from the resource id without also verifying membership.

final class EnsureOrganizationContext
{
    public function handle(Request $request, Closure $next)
    {
        $orgId = $request->route('organization')
            ?? $request->session()->get('current_organization_id');

        $membership = OrganizationUser::query()
            ->where('user_id', $request->user()->id)
            ->where('organization_id', $orgId)
            ->first();

        abort_unless($membership, 403);

        $request->attributes->set('organization_id', $membership->organization_id);
        $request->attributes->set('org_role', $membership->role);

        return $next($request);
    }
}

Gates, policies, and where org scope belongs

Laravel Gates are fine for coarse checks ($user->can('members.invite')) only if can is evaluated in org context. I bind a permission resolver that reads membership for the current org:

Gate::before(function (User $user, string $ability) {
    if ($user->isPlatformStaff() && $ability !== 'danger.purge') {
        return true; // platform ops; still scoped in queries below
    }
    return null;
});

Gate::define('members.invite', function (User $user) {
    $orgId = request()->attributes->get('organization_id');
    $role  = OrganizationUser::query()
        ->where('user_id', $user->id)
        ->where('organization_id', $orgId)
        ->value('role');

    return $role && RolePermissions::allows($role, 'members.invite');
});

Policies must receive the model and prove tenancy:

class InvoicePolicy
{
    public function refund(User $user, Invoice $invoice): bool
    {
        $orgId = request()->attributes->get('organization_id');

        if ($invoice->organization_id !== $orgId) {
            return false; // IDOR stop
        }

        return Gate::forUser($user)->allows('invoices.refund');
    }
}

Never authorize with Gate::allows('invoices.refund') alone and then Invoice::find($id). The find must be scoped:

$invoice = Invoice::query()
    ->where('organization_id', $request->attributes->get('organization_id'))
    ->whereKey($id)
    ->firstOrFail();

$this->authorize('refund', $invoice);

Multiple labeled keyrings hanging on a wall hooks board, metaphor for per-organization roles and keys that must not open the wrong door
AI-generated image.

Packages vs custom RBAC

Approach Fits multi-org well? Speed to first portal Risk When I choose it
Spatie laravel-permission (teams/org feature) Yes if teams enabled carefully Fast Misconfigured global roles Standard CRUD portals
Custom membership + permission tables Excellent Medium You own edge cases Complex org hierarchies
Single global role on users Poor Fast Systemic IDOR Almost never for portals
OIDC groups from IdP only Good for enterprise SSO Medium Sync lag, missing local deny When customer mandates IdP
Row-level Postgres policies Strong defense in depth Slower Migration/ops complexity High-assurance tenants

Spatie’s teams mode can map to organization_id if every assignRole and givePermissionTo call passes the team id. The failure mode is assigning a role without the team argument once—permission becomes global. Custom tables make that mistake a schema impossibility. For dealer-style portals with tiered commercial rules, I often keep RBAC custom and keep pricing separate; see B2B dealer portals: tier pricing, credit limits, bulk.

Failure modes that create IDOR

Resource id without org predicate. /invoices/uuid with a global find is the classic. Always constrain by organization_id from context, not from the invoice’s org alone after load without comparison.

User belongs to Org A and Org B; session sticky wrong. Switching orgs must rotate current_organization_id and invalidate cached abilities. Cached Gate results across orgs are poison.

API tokens without org claim. Personal access tokens that authorize any membership the user has must still require an org header or embed organization_id in the token abilities.

Platform staff bypass without query scope. Gate::before returning true must not skip where organization_id. Staff tools should set an explicit org context or use a separate break-glass query path that is audited.

Nested resources. /orgs/{org}/projects/{project}/files/{file} needs every parent checked, not only the leaf policy. Prefer signed route bindings that resolve through the org relationship.

Feature flags per tenant without authz. Flags control rollout, not permission. Pair with RBAC; see feature flags for multi-tenant SaaS.

Uploads and signed URLs. File access must re-check org membership on download, not only on upload—covered in spirit by secure SaaS file uploads.

Practical authorization checklist

  • [ ] Membership table: unique (organization_id, user_id) with role
  • [ ] Permissions are strings; roles map to permission sets
  • [ ] Middleware sets organization_id after membership proof
  • [ ] Every sensitive query includes organization_id = context
  • [ ] Policies compare model org to context before ability checks
  • [ ] Org switch clears ability/permission cache
  • [ ] API tokens carry or require org scope
  • [ ] Platform staff bypass still org-scoped in queries + audited
  • [ ] Automated tests: user in Org A cannot read Org B by id
  • [ ] Manual IDOR suite on every new resource controller

Example Pest test I require before merge:

it('blocks cross-organization invoice refund', function () {
    $user = User::factory()->create();
    $orgA = Organization::factory()->create();
    $orgB = Organization::factory()->create();
    OrganizationUser::factory()->create([
        'user_id' => $user->id,
        'organization_id' => $orgA->id,
        'role' => 'admin',
    ]);
    $invoiceB = Invoice::factory()->create(['organization_id' => $orgB->id]);

    $this->actingAs($user)
        ->withSession(['current_organization_id' => $orgA->id])
        ->post(route('invoices.refund', $invoiceB))
        ->assertForbidden();
});

Rollout framework for an existing single-tenant app

  1. Introduce organizations and membership; attach existing users to one org.
  2. Add organization_id to tenant-owned tables (expand/contract migration).
  3. Middleware + session org context before flipping enforcement.
  4. Convert Gates to org-aware checks; add policy org comparisons.
  5. Ship IDOR tests for the top ten money and PII routes.
  6. Only then allow users to join a second organization.

Skip step order and you will “support multi-org” in the UI while the API still trusts global roles.

Frequently asked questions

Is a global is_admin flag ever acceptable?

Only for platform operators of the SaaS itself, and even then every data query needs an explicit organization filter or a separate audited admin schema. Customer-org admins must never share that flag.

Should permissions live in the JWT?

You can embed a snapshot for performance, but the source of truth remains membership tables. Short TTL and server-side revoke still matter when roles change.

How many roles do I need?

Usually four to seven per portal (owner, admin, billing, operator, viewer, plus one vertical-specific role). More roles without clearer permissions become politics, not security.

Can one user have different roles in different orgs?

Yes—that is the point of membership rows. Do not collapse to the maximum role across orgs when evaluating an ability.

What about organization hierarchies (parent/child)?

Model parent links explicitly and define whether permissions inherit. Inheritance bugs are common; default to no inheritance until product requires it, then test lateral movement between siblings.

Package or custom for a greenfield portal?

If the portal is standard CRUD and teams map cleanly to orgs, a maintained package with teams mode is fine. If you have nested orgs, delegated admin, or unusual deny rules, custom membership tables are clearer for custom web application development long term.

Multi-organization RBAC is mostly discipline: context on the request, scope on every query, policies that refuse cross-org models. Get those three right and the package debate becomes secondary.

Follow & Connect

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.