Roles
The app ships six flat roles. Earlier docs described eight
(SuperAdmin / Admin / OrgUnitAdmin), but those were never implemented —
the RoleSeeder is the canonical catalog and it defines only the six
below.
Role membership is stored by spatie/laravel-permission in the standard
model_has_roles table. A user can hold exactly one of these roles at
a time in practice; the RBAC code doesn't prevent multi-role, but the
UI onboarding flows treat role as a single selection.
| Role | Summary |
|---|---|
system_admin | Platform owner. Holds every permission including the system.admin escape hatch. Used for multi-tenant ops, incident response, and one-off data repairs. |
org_admin | Tenant owner. Everything system_admin has except the system.admin flag. Owns the annual FY close (separation of duties from HR) and unit / org retirement. |
hr | HR operations: employee lifecycle (onboard / offboard), leave + regularization approvals, monthly attendance locks, device approvals, retroactive roster edits (shift_assignment.manage_past). Deliberately does not hold attendance.unlock — reopening a locked month is an org-admin action. |
manager | Team supervisor. First-stage approver for leave + regularization. Can approve device registrations for their team. Read-only on rosters; scheduling is the scheduler's job. |
scheduler | Full read/write on shifts and shift_assignments for today-and-future dates. Past-dated roster edits are blocked — that permission sits on hr / org_admin only. |
employee | Self-service only: view own attendance, file own leave + regularization, register own device. No scope.all permission — every controller that respects scope filters them down to rows tied to their user_id. |
Scope rule
Every non-employee role holds the scope.all permission, which
short-circuits the "self-only" row filter inside controllers. The
employee role is the single carve-out — their queries are always
narrowed to rows where the employee row's user_id matches the
authenticated user.
There is no per-row scope table (role_scopes from earlier designs
was dropped). If a deployment needs multi-tenant isolation (one HR per
org), that's enforced by the controllers rewriting queries to filter on
organization_id matching the HR's primary org, not by RBAC. See
Permissions for the resource-by-resource breakdown.
Special permissions worth calling out
shift_assignment.manage_past— Gate for editing a roster row whoseassigned_foris strictly before today. Held byhr,org_admin,system_admin.schedulerexplicitly lacks it: retroactive rewrites need HR sign-off.attendance.lock/attendance.unlock— Split across two permissions so HR can close out a month without being able to reopen it.hrgets only.lock;org_admin/system_adminget both.system.admin— Pure escape hatch, held only bysystem_admin. Used as aGate::beforebypass for operations the regular policy graph would block (e.g. cross-tenant incident response).panel.access— Filament admin panel gate. Held bysystem_admin,org_admin,hr. Scheduler / manager / employee access the UI through the React front-end only.
Assignment conventions
- Every test helper (
userWithRole('hr'),asSystemAdmin()) assigns a single role and seeds the role catalog on demand — safe to call inside a test that usesRefreshDatabase. - Role rename / split requires a DB migration (or a RoleSeeder edit + a
one-shot
model_has_rolespatch). Spatie caches the role→permission map in memory per-request;app(PermissionRegistrar::class)->forgetCachedPermissions()after any mutation.