Security, Permissions & Masking
HR data is the most sensitive in the system — salaries, national IDs, passports, IBANs. This page documents the controls that protect it. They are worth understanding whether you're testing, supporting, or vouching for the module.
Business view
Three layers of protection:
- Permission-gated pages and actions. Each HR page and every mutation sits
behind a specific permission. A receptionist doesn't see payroll; an accountant can view a run but only a PROCESS_PAYROLL holder can pay it.
- You only see your own personal data — unless you're a manager. Leave,
loans, payslips, timesheets, reviews: a regular employee querying these is automatically scoped to themselves. Only a manager permission lets you see others.
- Sensitive fields are masked. Bank details, IBAN, national ID, and passport
are blanked for anyone who isn't the employee or a senior HR/payroll manager — even if they can otherwise open the profile.
On top of that, self-serving actions are blocked: you can't approve your own leave, review yourself, or issue yourself a warning. And every "by id" action is tenant-scoped so one clinic can never touch another's records.
Technical view
Two cross-cutting helpers — resolvers/queries/actions/hr/hrQueries.js
canManageOthers(request)(:13) — true if the caller's permissions
intersect MANAGER_PERMS = ['DO_ALL','VIEW_SALARY_HUB','MANAGE_SALARY_HUB', 'MANAGE_HR','APPROVE_LEAVE','VIEW_ATTENDANCE'] (:12).
resolveTargetUserId(request, argUser)(:18) — returns self whenargUser
is empty or self; otherwise requires canManageOthers or throws hr.notAuthorizedForUser. This is the "non-managers auto-scoped to self" gate on every personal-data read.
scopeUser(strategicHrQueries.js:14) is the strategic-read equivalent.withUserNames(withUserNames.js:6) hydrates{id,name}for rows storing a
bare userId via a company-scoped in — a foreign/cross-tenant id resolves to null rather than leaking a name.
Sensitive-field masking — payrollQueries.js:86
In employeeProfile, canSeeSensitive = (userId === requesterId) || perms includes one of ['DO_ALL','MANAGE_HR','MANAGE_SALARY_HUB']. If false, the fields ['bankName','bankAccountNumber','iban','swiftCode','nationalId','passportNumber'] are set to null (fully nulled, not partially masked). So a viewer with only VIEW_ATTENDANCE can open the drawer but sees those blanked; the employee always sees their own.
Masking is read-side only. The write paths (updateEmployeeRecord,bulkUpdateEmployeeComp) store these fields normally — the master-mutation file's header comment about masking is not where masking actually lives.
Self-serving actions blocked
- Leave review:
hrMutations.js:320(cannotReviewOwnLeave). - Performance review, both paths:
strategicHrMutations.js:229,:234
(cannotReviewSelf).
- Warnings:
strategicHrMutations.js:186(cannotWarnSelf). - Manage-self on the employee record:
employeeMasterMutations.js:91
(cannotManageSelf).
- Ownership-gated self-service:
acknowledgeWarning(:204),acknowledgeReview
(:250).
Tenant scoping
Every upsert/delete and every referenced foreign key is company-scoped via assertInCompany / assertOwn / company-scoped findFirst. workSchedule adds an extra user.count({id, companyId}) guard (hrQueries.js:335) because it's keyed only by userId; withUserNames's company-scoped in closes the same cross-tenant seam.
The permission map
Configured in permissions/permissions.js (GraphQL Shield).
| Permission | Grants |
|---|---|
VIEW_SALARY_HUB | salaryProfiles, payrollSettings, payrollRuns, payrollRunDetails, leaveLiabilityReport, punctualityByBranch, payrollCostTrend, eosSummary, hrAnalytics |
MANAGE_HR | vacancies, jobApplications, checklistTemplates; all master-data + strategic upserts/deletes; updateEmployeeRecord |
MANAGE_SALARY_HUB | salary components, employee loans, bulkUpdateEmployeeComp, updateSalaryProfile, updatePayrollSettings, commission policies |
PROCESS_PAYROLL | wpsFile; startPayrollRun, updatePayrollRunLine, confirmPayrollRun, processPayrollRun, cancelPayrollRun, payEndOfService |
APPROVE_LEAVE | approveLeaveRequest, rejectLeaveRequest (and assigning leave) |
REQUEST_LEAVE | createLeaveRequest (also allowed with APPROVE_LEAVE) |
VIEW_OWN_SALARY | mySalary |
MANAGE_ATTENDANCE | correctAttendance, setOvertimeApproval |
VIEW_ATTENDANCE | contributes to hrToday visibility |
Many reads are isAuthenticated at the shield but self-scoped inside the resolver via resolveTargetUserId (holidays, leaveTypes, leaveRequests, leaveBalance, workSchedule, departments, positions, schedulePeriods, salaryComponents, employeeLoans, timesheet, orgChart, employeeProfile, and all strategic personal-data reads). OR-gated examples: hrToday, teamRoster, leaveCoverage.
Router-level guard (web)
HrLayout.js:23 redirects a user to the first HR page they're permitted to see, or shows a 403 if they have none — so the UI never dead-ends on a forbidden page.
Input validation
Length caps and format checks in permissions/inputRules.js (IBAN regex, numeric ranges, required fields) provide defense-in-depth against malformed or oversized input. See the Quality page for the exact limits to test.