Dentolize · HR Module Walkthrough
On this pageBusiness viewTechnical view

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:

  1. 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.

  1. 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.

  1. 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 when argUser

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).

PermissionGrants
VIEW_SALARY_HUBsalaryProfiles, payrollSettings, payrollRuns, payrollRunDetails, leaveLiabilityReport, punctualityByBranch, payrollCostTrend, eosSummary, hrAnalytics
MANAGE_HRvacancies, jobApplications, checklistTemplates; all master-data + strategic upserts/deletes; updateEmployeeRecord
MANAGE_SALARY_HUBsalary components, employee loans, bulkUpdateEmployeeComp, updateSalaryProfile, updatePayrollSettings, commission policies
PROCESS_PAYROLLwpsFile; startPayrollRun, updatePayrollRunLine, confirmPayrollRun, processPayrollRun, cancelPayrollRun, payEndOfService
APPROVE_LEAVEapproveLeaveRequest, rejectLeaveRequest (and assigning leave)
REQUEST_LEAVEcreateLeaveRequest (also allowed with APPROVE_LEAVE)
VIEW_OWN_SALARYmySalary
MANAGE_ATTENDANCEcorrectAttendance, setOvertimeApproval
VIEW_ATTENDANCEcontributes 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.