Skip to main content

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.

Permissionsystem_adminorg_adminhrmanagerscheduleremployee
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_admin cannot 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 through system_admin. org_admin and HR can still create and delete rows during onboarding/offboarding; only the in-place edit is locked. OrganizationPolicy::update / OrganizationUnitPolicy::update enforce 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 owns shift.create and shift.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::update enforces this.
  • HR cannot unlock attendance. HR holds attendance.lock but not .unlock. This is a deliberate separation of duties: HR closes the month, org_admin reopens it if a correction is later needed.
  • Scheduler cannot edit the past. shift_assignment.manage_past is HR+ only. RosterDateGate::userMayWrite() is the single source of truth checked by both ShiftAssignmentPolicy and RosterController::store.
  • Employees cannot see other employees. employee lacks employee.view. Their own employee row is fetched via /auth/me, not /employees/{id}.
  • Managers cannot create rosters. manager has no shift_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​

  1. Add the string to RoleSeeder::permissionCatalog().
  2. Attach it to the appropriate role arrays in RoleSeeder::roleMatrix().
  3. Reference it in the relevant policy / controller / UI nav filter.
  4. Run php artisan db:seed --class=RoleSeeder in every environment — the seeder is idempotent.
  5. Update this table. If you skip this step, reviewers will think the permission was added by accident.