Permissions
Permissions follow a <resource>.<action> pattern and live in
spatie_permissions (single guard_name = 'web'). The canonical
catalog is RoleSeeder::permissionCatalog() — that method defines
every permission the backend + UI can reference. Adding a new
permission means editing that method and attaching it to the
appropriate roles in RoleSeeder::roleMatrix().
The matrix
Legend: ✓ = granted, — = not granted. Cells match
RoleSeeder::roleMatrix() exactly; if this table drifts, the seeder is
authoritative.
| Permission | system_admin | org_admin | hr | manager | scheduler | employee |
|---|---|---|---|---|---|---|
| Organizations | ||||||
organization.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
organization.create | ✓ | ✓ | — | — | — | — |
organization.update | ✓ | — | — | — | — | — |
organization.delete | ✓ | ✓ | — | — | — | — |
| Units | ||||||
unit.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
unit.create | ✓ | ✓ | ✓ | — | — | — |
unit.update | ✓ | — | — | — | — | — |
unit.delete | ✓ | ✓ | — | — | — | — |
| Users (account rows) | ||||||
user.view | ✓ | ✓ | ✓ | — | — | — |
user.create | ✓ | ✓ | ✓ | — | — | — |
user.update | ✓ | ✓ | ✓ | — | — | — |
user.delete | ✓ | ✓ | — | — | — | — |
| Employees (HR records) | ||||||
employee.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
employee.create | ✓ | ✓ | ✓ | — | — | — |
employee.update | ✓ | ✓ | ✓ | — | — | — |
employee.delete | ✓ | ✓ | ✓ | — | — | — |
| Employee deployments | ||||||
employee_deployment.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
employee_deployment.create | ✓ | ✓ | ✓ | — | — | — |
employee_deployment.update | ✓ | ✓ | ✓ | — | — | — |
employee_deployment.delete | ✓ | ✓ | ✓ | — | — | — |
| Shifts | ||||||
shift.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
shift.create | ✓ | ✓ | — | — | ✓ | — |
shift.update | ✓ | — | — | — | — | — |
shift.delete | ✓ | ✓ | — | — | ✓ | — |
| Shift assignments (roster) | ||||||
shift_assignment.view | ✓ | ✓ | ✓ | ✓ | ✓ | — |
shift_assignment.create | ✓ | ✓ | ✓ | — | ✓ | — |
shift_assignment.update | ✓ | ✓ | ✓ | — | ✓ | — |
shift_assignment.delete | ✓ | ✓ | — | — | ✓ | — |
shift_assignment.manage_past | ✓ | ✓ | ✓ | — | — | — |
| Attendance | ||||||
attendance.view | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
attendance.process | ✓ | ✓ | ✓ | ✓ | — | — |
attendance.lock | ✓ | ✓ | ✓ | — | — | — |
attendance.unlock | ✓ | ✓ | — | — | — | — |
| Leave | ||||||
leave.view | ✓ | ✓ | ✓ | ✓ | — | ✓ |
leave.create | ✓ | ✓ | ✓ | — | — | ✓ |
leave.delete | ✓ | ✓ | ✓ | — | — | — |
leave.manager_approve | ✓ | ✓ | ✓ | ✓ | — | — |
leave.hr_approve | ✓ | ✓ | ✓ | — | — | — |
| Regularization | ||||||
regularization.view | ✓ | ✓ | ✓ | ✓ | — | ✓ |
regularization.create | ✓ | ✓ | ✓ | — | — | ✓ |
regularization.delete | ✓ | ✓ | ✓ | — | — | — |
regularization.manager_approve | ✓ | ✓ | ✓ | ✓ | — | — |
regularization.hr_approve | ✓ | ✓ | ✓ | — | — | — |
| Devices | ||||||
device.view | ✓ | ✓ | ✓ | ✓ | — | ✓ |
device.register | ✓ | ✓ | — | — | — | ✓ |
device.approve | ✓ | ✓ | ✓ | ✓ | — | — |
device.revoke | ✓ | ✓ | ✓ | — | — | — |
device.delete | ✓ | ✓ | — | — | — | — |
| Holidays | ||||||
holiday.view | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
holiday.create | ✓ | ✓ | ✓ | — | — | — |
holiday.update | ✓ | ✓ | ✓ | — | — | — |
holiday.delete | ✓ | ✓ | ✓ | — | — | — |
| Red-flag policies | ||||||
red_flag_policy.view | ✓ | ✓ | ✓ | ✓ | — | — |
red_flag_policy.create | ✓ | ✓ | ✓ | — | — | — |
red_flag_policy.update | ✓ | ✓ | ✓ | — | — | — |
red_flag_policy.delete | ✓ | ✓ | ✓ | — | — | — |
| Audit & reports | ||||||
audit.view | ✓ | ✓ | ✓ | — | — | — |
report.view | ✓ | ✓ | ✓ | ✓ | — | — |
export.attendance | ✓ | ✓ | ✓ | — | — | — |
export.roster | ✓ | ✓ | ✓ | — | — | — |
| Meta permissions | ||||||
scope.all | ✓ | ✓ | ✓ | ✓ | ✓ | — |
panel.access | ✓ | ✓ | ✓ | — | — | — |
system.admin | ✓ | — | — | — | — | — |
Policy pattern
Every resource has a Laravel policy class (LeavePolicy,
AttendancePolicy, etc.). Inside each method the check is literally:
public function view(User $user, Leave $leave): bool
{
if (! $user->can('leave.view')) {
return false;
}
// scope.all holders skip the self-only filter; employees only see
// their own rows.
if ($user->can('scope.all')) {
return true;
}
return $leave->employee->user_id === $user->id;
}
The hasAnyRole() / role-name string matching that lived in earlier
versions was replaced during task #55 — permissions are the only
contract now.
Notable gating decisions
- Corporate-client + OHC-site UPDATE are super-admin only. Even
org_admincannot rename a tenant or rotate its PAN, and HR cannot edit an OHC site's name/geofence. Every downstream attendance row buckets against these identifiers — silently mutating them in production has caused enough cleanup work that we routed all edits throughsystem_admin.org_adminand HR can still create and delete rows during onboarding/offboarding; only the in-place edit is locked.OrganizationPolicy::update/OrganizationUnitPolicy::updateenforce this at the policy layer. - Shift template UPDATE is super-admin only. Same rationale —
editing a shift's start/end window or grace bands silently re-
evaluates every historical attendance row that bucketed against
that shift, which produces "why did my punch suddenly become late?"
tickets. Even the
scheduler(who ownsshift.createandshift.delete) can't mutate a template in place. The production playbook is "create a replacement shift, retire the old one"; scheduler can do both halves of that without escalating.ShiftPolicy::updateenforces this. - HR cannot unlock attendance. HR holds
attendance.lockbut not.unlock. This is a deliberate separation of duties: HR closes the month,org_adminreopens it if a correction is later needed. - Scheduler cannot edit the past.
shift_assignment.manage_pastis HR+ only.RosterDateGate::userMayWrite()is the single source of truth checked by bothShiftAssignmentPolicyandRosterController::store. - Employees cannot see other employees.
employeelacksemployee.view. Their own employee row is fetched via/auth/me, not/employees/{id}. - Managers cannot create rosters.
managerhas noshift_assignment.create— approving a leave doesn't implicitly give them roster-write. A shift change to cover an approved absence is a separate scheduler action. - Device approve ≠ device revoke. Managers can approve a new device (they're the line manager confirming "yes, this is my person"), but revoking an approved device is HR's job.
Adding a new permission
- Add the string to
RoleSeeder::permissionCatalog(). - Attach it to the appropriate role arrays in
RoleSeeder::roleMatrix(). - Reference it in the relevant policy / controller / UI nav filter.
- Run
php artisan db:seed --class=RoleSeederin every environment — the seeder is idempotent. - Update this table. If you skip this step, reviewers will think the permission was added by accident.