Skip to main content

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.

RoleSummary
system_adminPlatform owner. Holds every permission including the system.admin escape hatch. Used for multi-tenant ops, incident response, and one-off data repairs.
org_adminTenant owner. Everything system_admin has except the system.admin flag. Owns the annual FY close (separation of duties from HR) and unit / org retirement.
hrHR 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.
managerTeam supervisor. First-stage approver for leave + regularization. Can approve device registrations for their team. Read-only on rosters; scheduling is the scheduler's job.
schedulerFull 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.
employeeSelf-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 whose assigned_for is strictly before today. Held by hr, org_admin, system_admin. scheduler explicitly 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. hr gets only .lock; org_admin / system_admin get both.
  • system.admin — Pure escape hatch, held only by system_admin. Used as a Gate::before bypass for operations the regular policy graph would block (e.g. cross-tenant incident response).
  • panel.access — Filament admin panel gate. Held by system_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 uses RefreshDatabase.
  • Role rename / split requires a DB migration (or a RoleSeeder edit + a one-shot model_has_roles patch). Spatie caches the role→permission map in memory per-request; app(PermissionRegistrar::class)->forgetCachedPermissions() after any mutation.