Skip to main content

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_id to users (or a model_has_scope pivot).
  • Introduce a global Eloquent scope on Organization, OrganizationUnit, Employee, ShiftAssignment etc. that narrows queries to the caller's org.
  • Flip scope.all into 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.