This is the next entry in a build-in-public series where I extract a real,
production Laravel CRM into a reusable SaaS core, one module at a time, and ship each piece as a Composer package. Previous entries covered the foundation layer, authentication on top of Fortify, and multi-tenancy. This one is v0.4.0: roles and permissions.
And I'll start with the decision that surprises people.
I didn't use Spatie
spatie/laravel-permission is excellent. I've used it. If you're starting a fresh app, reach for it.
I didn't, for one specific reason: the RBAC in my CRM was already tenant-scoped, and not as an afterthought. Every role assignment and every individual permission grant carries the company it belongs to. The same user can be an admin in company A and a read-only member in company B, and a permission granted in A must never leak into B. My multi-tenancy layer (the previous release) makes company_id the tenant key everywhere, and the access layer was built around that from day one.
Wrapping a generic package to get back to per-tenant roles, custom grant/revoke overrides, and "default roles cloned into every new company" would have been more work than extracting the code that already did exactly that. So I lifted mine, modernized it, and put it through the same security and code review every module gets. (And yes, I say "self-written, not Spatie" in every post about it, because honesty is the whole point of building in public.)
The shape of it
Five tables, nothing exotic:
-
permissions- the catalog. Globally-unique slugs inmodule.actionform, likecompany.roles.update. Seeded from config, never invented at runtime. -
roles- named bundles of permissions. Acompany_id(nullable) scopes a role to a tenant. Flags mark whether a role is global, a template, or a custom role an owner made. -
role_permissions- which permissions a role grants. -
user_roles- a user's roles, each row carrying acompany_id(the tenant). -
user_permissions- individual grants and revokes, also tenant-scoped, with anis_revokedflag.
The interesting part is not the tables, it's the check. A permission lookup runs in a strict priority order that reads like a sentence:
public function hasPermissionTo(Permission|string $permission, Company|int|null $company = null): bool
{
if ($this->isSuperAdmin()) {
return true;
}
if ($company !== null && $this->userOwns($company)) {
return true;
}
$slug = $permission instanceof Permission ? $permission->slug : $permission;
return $this->getAllPermissions($company)->contains($slug);
}
Super-admin first. Then the company owner (everything in their own company). Then, for everyone else, the resolved permission set, which is:
(permissions from company roles) plus (permissions from global roles) plus
(individual grants), minus (individual revokes).
Because the (user, permission, company) row is unique, a permission is either granted or revoked, never both, so that set arithmetic reproduces the old "revoke beats grant beats role" order exactly, while resolving the whole set once per request instead of firing a query per check.
Two bypasses, and only two. The owner bypass reuses the is_owner flag the tenancy layer already owns, so RBAC sits on top of multi-tenancy without duplicating the concept of ownership. Super-admin is an identity flag resolved through the auth layer, never a role, so it can't be granted by accident from a role-management screen.
The hook that finally fired
When I shipped multi-tenancy, company creation dispatched a CompanyCreated event that did nothing yet. I wrote in that release that RBAC would listen to it later.
It does now. A queued listener clones a set of template roles into every new company the moment it's created, so a brand-new tenant starts with sensible roles instead of an empty access table. It's idempotent (a retry won't duplicate roles), and it's queued on purpose: the owner already has full access through the owner bypass, so they never wait on the clone. The cloned roles only matter for the employees they invite later, by which point the job has run.
Then the review found the hole
Every module in this series goes through a code review before it merges, and every module so far has had the review catch something real. This one was a privilege-escalation hole, and it's a good one because the code looked correct.
I wanted owners to be able to delegate employee management. "Let my office manager invite people and assign their roles" is a reasonable thing for a SaaS to support.
So there's a permission, company.employees.grant_permissions, and the endpoint that grants permissions to a member checked for it:
public function updatePermissions(Request $request, int $user): RedirectResponse
{
$this->authorize('company.employees.grant_permissions');
// ... validate the requested permission slugs against the catalog ...
// ... grant them to the target member ...
}
Spot it? The gate checks "are you allowed to grant permissions." It does not check "do you actually hold the permission you're handing out." The requested slugs were validated only against the catalog, which contains every permission in the system, including the ones that manage roles.
So a delegated member with grant_permissions could grant themselves, or anyone else, ANY permission. Including company.roles.delete, company.employees.remove, or the grant permission itself. The office manager you trusted to invite people could quietly promote themselves to full role-admin. A textbook confused deputy: the deputy was allowed to act, but nobody bounded what they were allowed to act with.
The fix is a single rule: you can only hand out what you already hold.
$held = $request->user()->getAllPermissions($company);
foreach ($validated['grant'] ?? [] as $slug) {
abort_unless($held->contains($slug), 403);
}
The actor's own effective permission set bounds every grant. Owners and
super-admins hold the entire catalog (the bypass returns everything), so nothing changes for them. A delegated member is capped at exactly their own access, and can never grant past it. The same rule applies to assigning roles: you can only assign a role whose permissions are a subset of what you hold, so you can't smuggle a powerful role to someone when you couldn't grant its permissions directly. Two regression tests fail loudly if either ever regresses.
And a second one, hiding in the flow
The review found a quieter bug too, the kind no single function reveals because it's about state moving across a whole flow.
Removing a member from a company is a soft delete: the membership row stays for audit, just flagged removed. But removing a member did nothing to their role assignments. Those user_roles rows just sat there. Two consequences: a custom role that only removed members held could never reach zero holders, so it became permanently undeletable through the UI, and the removed member's grants lingered in the table where a future code path could resolve them.
The fix sits at the right altitude. The tenancy layer already fires an
EmployeeRemoved event. RBAC listens to it:
class RevokeAccessOnEmployeeRemoval
{
public function handle(EmployeeRemoved $event): void
{
$employee = $event->employee;
$companyId = $event->company->getKey();
$employee->roles()->wherePivot('company_id', $companyId)->detach();
$employee->permissions()->wherePivot('company_id', $companyId)->detach();
}
}
Leave a company, lose your access in that company. Global roles (like the base "authenticated" role every user carries) are untouched, because leaving one company should never change your identity everywhere else.
What it deliberately is not
Same honesty note I put on every release. v0.4.0 ships the RBAC engine and exactly one neutral starter role. It does not ship a pile of business permissions. Orders, inventory, billing actions, all of that belongs to the app you build on top, not the core. The core gives you the mechanism and a config catalog you extend.
The billing-aware access check is still a stub behind an interface (it returns "allowed" until the billing module lands), and the API-token resolver for non-session clients comes in a later phase. I'd rather ship a small honest engine than a big pretend one.
Tests, and the close
The package is green: the RBAC suite covers the check priority, tenant isolation, the clone-on-create job, the two security fixes above, and the Vue permission picker, on top of the host integration smoke that proves it all wires into the real app through the real middleware stack.
The pattern that keeps repeating in this series: the extraction itself is rarely where the bugs are. They show up when a second pass traces state across a whole flow, or asks "this gate lets you act, but what bounds what you act with." That's the part worth slowing down for.
Next up is the activity log.
The package is public on GitHub: https://github.com/dmitryisaenko/larafoundry
Top comments (0)