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.