Scopes
Scopes control which rows a role can see. Earlier designs
envisioned a role_scopes table keyed by (user_id, scope_type, organization_id, unit_id); that table was never built. What actually
ships is simpler:
The scope.all permission
Every non-employee role holds scope.all. Holders see every row their
role permits them to see. The employee role is the sole carve-out —
it lacks scope.all, and every controller that serves employee-owned
resources (leaves, regularizations, attendance) falls back to a
self-only filter when the caller doesn't have scope.all.
The pattern inside policies:
public function view(User $user, Leave $leave): bool
{
if (! $user->can('leave.view')) {
return false;
}
if ($user->can('scope.all')) {
return true;
}
return $leave->employee->user_id === $user->id;
}
And inside controllers that list-and-filter:
$query = Leave::query();
if (! $user->can('scope.all')) {
$query->whereHas('employee', fn ($q) => $q->where('user_id', $user->id));
}
Why not per-org isolation out of the box?
The deployments we've shipped to date run a single tenant per DB
schema (see DEPLOY.md — one .env per org, one MySQL schema). In
that posture, cross-tenant leaking is physically impossible: there
are no rows from another tenant to leak.
Multi-tenant single-DB deployments would need to rebuild the scope filter. The sketch:
- Add
organization_idtousers(or amodel_has_scopepivot). - Introduce a global Eloquent scope on
Organization,OrganizationUnit,Employee,ShiftAssignmentetc. that narrows queries to the caller's org. - Flip
scope.allinto a tenant-wide flag, not a global one.
This work is tracked in the roadmap but not yet implemented.
The one place that already does per-tenant scoping
AttendanceLocker cares about scope because a lock is per-org or
per-unit. The lock/unlock pair rewrites the Attendance update to
whereHas('shiftAssignment.unit' => fn ($q) => $q->where('organization_id', $lock->organization_id))
even when the lock is org-wide. A regression in that filter would
have cross-tenant consequences — covered by
AttendanceLockerTest::REGRESSION: org-wide unlock does NOT clear other tenants.
Testing
Policy tests live under tests/Feature/Policies/:
PolicyUnitTest.php— per-policy method coverage.RbacMatrixTest.php— every role × every policy method exhaustive matrix. Keeps the seeder and the policy code honest against each other.
Add a new policy method → add a row to RbacMatrixTest. The matrix is
parameterised so one new entry is a few lines, not a full rewrite.