@kernhq/module-hr 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +15 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +8 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +20 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/privacy.d.ts +860 -0
- package/dist/contract/privacy.d.ts.map +1 -0
- package/dist/contract/privacy.js +412 -0
- package/dist/contract/privacy.js.map +1 -0
- package/dist/contract/router.d.ts +815 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +105 -0
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +876 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +312 -2
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +397 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +109 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/audit.d.ts +249 -0
- package/dist/server/services/audit.d.ts.map +1 -0
- package/dist/server/services/audit.js +230 -0
- package/dist/server/services/audit.js.map +1 -0
- package/dist/server/services/people.d.ts +53 -1
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +74 -1
- package/dist/server/services/people.js.map +1 -1
- package/dist/server/services/privacy.d.ts +514 -0
- package/dist/server/services/privacy.d.ts.map +1 -0
- package/dist/server/services/privacy.js +972 -0
- package/dist/server/services/privacy.js.map +1 -0
- package/migrations/0011_privacy.sql +77 -0
- package/migrations/meta/0011_snapshot.json +4450 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +15 -0
- package/src/contract/permissions.ts +22 -0
- package/src/contract/privacy.ts +452 -0
- package/src/contract/router.ts +121 -0
package/package.json
CHANGED
package/src/contract/index.ts
CHANGED
package/src/contract/models.ts
CHANGED
|
@@ -71,6 +71,21 @@ export const Person = z.object({
|
|
|
71
71
|
* from data the directory does not even fetch. So the one place that does the nulling says so.
|
|
72
72
|
*/
|
|
73
73
|
personnelHidden: z.boolean().default(false),
|
|
74
|
+
/**
|
|
75
|
+
* When this person's record was erased on request, or null.
|
|
76
|
+
*
|
|
77
|
+
* Erasure is redaction, not deletion: the identifying columns are cleared and the rows a payroll
|
|
78
|
+
* audit needs survive against a person who is no longer nameable. So an erased record still
|
|
79
|
+
* appears — in a headcount, behind a ledger entry, as the subject of an approval from two years
|
|
80
|
+
* ago — and a screen that cannot tell it apart from an ordinary one shows a blank name and lets
|
|
81
|
+
* the reader conclude the record is broken.
|
|
82
|
+
*
|
|
83
|
+
* The date rather than a boolean, because the two questions a reader has are "is this erased" and
|
|
84
|
+
* "when", and the first is `!== null`. `erasedBy` and `erasureReason` are deliberately *not* here:
|
|
85
|
+
* they answer an administrator's question, not a directory's, and putting them on the model every
|
|
86
|
+
* screen fetches would spread them further than the act they describe.
|
|
87
|
+
*/
|
|
88
|
+
erasedAt: Timestamp.nullable(),
|
|
74
89
|
createdAt: Timestamp,
|
|
75
90
|
updatedAt: Timestamp,
|
|
76
91
|
})
|
|
@@ -304,6 +304,27 @@ export const hrPermissions = definePermissions([
|
|
|
304
304
|
dangerous: false,
|
|
305
305
|
},
|
|
306
306
|
|
|
307
|
+
// ---------------------------------------------------------------- privacy
|
|
308
|
+
{
|
|
309
|
+
key: 'hr.privacy.manage',
|
|
310
|
+
label: 'Handle privacy requests: export, erase and retention',
|
|
311
|
+
description:
|
|
312
|
+
'Subject access requests, erasure and retention horizons under GDPR and KVKK. An export ' +
|
|
313
|
+
'contains decrypted bank and identity details, and an erasure cannot be undone. Grant to a ' +
|
|
314
|
+
'named data-protection owner, not to a role.',
|
|
315
|
+
scope: 'workspace',
|
|
316
|
+
// Nobody by default, like `hr.person.view_sensitive` and for the same reason: whether anybody
|
|
317
|
+
// below an owner may erase a colleague is the workspace's decision, and it has to be made
|
|
318
|
+
// deliberately rather than inherited by everyone who happens to be an admin.
|
|
319
|
+
defaultRoles: [],
|
|
320
|
+
dangerous: true,
|
|
321
|
+
},
|
|
322
|
+
// One key, four procedures, one commit — `privacy.subjectAccess`, `privacy.erase`,
|
|
323
|
+
// `privacy.retention.get` and `privacy.retention.set` all ship gated on it. Four screens suggest
|
|
324
|
+
// four keys and the temptation is real; the cost of taking it is recorded twice in this repository
|
|
325
|
+
// already, at the removed `hr.overtime.*` keys above and at `directoryVisibleToMembers` in
|
|
326
|
+
// `settings.ts`. A separate `hr.privacy.view` arrives when a procedure asks for it and not before.
|
|
327
|
+
|
|
307
328
|
// ---------------------------------------------------------------- fields
|
|
308
329
|
{
|
|
309
330
|
key: 'hr.field.manage',
|
|
@@ -352,4 +373,5 @@ export const HR_PERMISSIONS = {
|
|
|
352
373
|
periodManage: 'hr.period.manage',
|
|
353
374
|
approvalManage: 'hr.approval.manage',
|
|
354
375
|
approvalDelegate: 'hr.approval.delegate',
|
|
376
|
+
privacyManage: 'hr.privacy.manage',
|
|
355
377
|
} as const
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { Timestamp, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { ApprovalDecision, ApprovalRequest, ApprovalStep, Delegation } from './approvals.js'
|
|
4
|
+
import { AttendanceDay, Punch, Regularization } from './attendance.js'
|
|
5
|
+
import { LeaveLedgerEntry, LeaveRequest, LeaveType } from './leave.js'
|
|
6
|
+
import { Employment, IsoDate, OfficeAssignment, Person, PersonDocument, PersonSensitive } from './models.js'
|
|
7
|
+
import { ResolvedPolicy } from './policies.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Subject access, erasure and retention — the three things a data-protection law asks a personnel
|
|
11
|
+
* system for, and the one part of HR whose whole job is to say what it did.
|
|
12
|
+
*
|
|
13
|
+
* Three positions hold this file together, and each of them is a decision the rest of the module
|
|
14
|
+
* already made somewhere else:
|
|
15
|
+
*
|
|
16
|
+
* - **Erasure is redaction, never deletion.** `leave_ledger` is append-only because a balance is the
|
|
17
|
+
* sum of it and nothing else; `punches` is append-only because an attendance record somebody can
|
|
18
|
+
* quietly rewrite is worth nothing in the dispute it exists for; `employments` is effective-dated
|
|
19
|
+
* so March is still answerable in June. Deleting an erased person's rows would break all three and
|
|
20
|
+
* would answer a payroll audit with "she left, so we deleted her file". So erasure clears the
|
|
21
|
+
* columns that identify a person and leaves every record that a wage, an entitlement or an
|
|
22
|
+
* authorisation was computed from. Afterwards the ledger still has somebody to attribute a day
|
|
23
|
+
* to, and nobody has a name.
|
|
24
|
+
* - **It says what it kept, per class, and why.** The `kept` half of the report is not a courtesy:
|
|
25
|
+
* an erasure that silently retains is the same failure as an export that silently omits, and the
|
|
26
|
+
* only difference is which of the two the subject finds out about later.
|
|
27
|
+
* - **Nothing here is a capability.** A workspace that could switch privacy off would be a workspace
|
|
28
|
+
* that stopped honouring subject requests, which fails the capability registry's own second rule —
|
|
29
|
+
* a switch must be reversible without destroying data, and this one would not even be a switch
|
|
30
|
+
* anybody should be offered. One permission key, `hr.privacy.manage`, gates all four procedures,
|
|
31
|
+
* and it ships in the same change as them.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const ws = { workspaceId: WorkspaceId }
|
|
35
|
+
|
|
36
|
+
// =====================================================================================
|
|
37
|
+
// retention
|
|
38
|
+
// =====================================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A horizon in days, or null.
|
|
42
|
+
*
|
|
43
|
+
* **Null is the shipped value for every class and it means "keep indefinitely".** "Seven years" is a
|
|
44
|
+
* fact about one country and one document class, not about the world, so no number here is a
|
|
45
|
+
* default: the suggested figures live in the help text on the settings screen, beside the sentence
|
|
46
|
+
* saying Kern gives no legal advice. The workspace owner sets them, and where their legal entities
|
|
47
|
+
* differ they set them per entity — which this shape cannot yet express, and says so below.
|
|
48
|
+
*/
|
|
49
|
+
const RetentionDays = z.number().int().min(1).max(36_500).nullable().default(null)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The classes retention is expressed in.
|
|
53
|
+
*
|
|
54
|
+
* They follow the redact/retain split rather than the table list, because that is the split a
|
|
55
|
+
* retention decision is actually made in: "how long do we keep the evidence behind a day sheet" is
|
|
56
|
+
* one question whatever tables it lands in.
|
|
57
|
+
*/
|
|
58
|
+
export const RetentionClass = z.enum([
|
|
59
|
+
/** Where a punch happened, on what device, and what was typed on it. */
|
|
60
|
+
'punchDetail',
|
|
61
|
+
/** The punch rows themselves — the evidence behind a day sheet. */
|
|
62
|
+
'punches',
|
|
63
|
+
/** The derived day sheet: hours, overtime, lateness. This is pay. */
|
|
64
|
+
'attendanceDays',
|
|
65
|
+
/** Ledger movements and the requests behind them. Also pay. */
|
|
66
|
+
'leave',
|
|
67
|
+
/** The audit trail. At the horizon the values go and the rows stay — see `privacy.erase`. */
|
|
68
|
+
'personHistory',
|
|
69
|
+
/** Contracts, payslips, identity scans. */
|
|
70
|
+
'personDocuments',
|
|
71
|
+
/** People who have left, and have not been erased. */
|
|
72
|
+
'terminatedPeople',
|
|
73
|
+
/** Who read somebody's identity, birth date or bank details. Itself personal data. */
|
|
74
|
+
'sensitiveAccessLog',
|
|
75
|
+
])
|
|
76
|
+
export type RetentionClass = z.infer<typeof RetentionClass>
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How long HR keeps each class, per workspace.
|
|
80
|
+
*
|
|
81
|
+
* A table rather than module settings, and the reason is mechanical: `core.settings.setModule`
|
|
82
|
+
* validates the merged blob against the module's declared zod schema and a zod object strips what it
|
|
83
|
+
* does not declare — so a retention key stored beside `country` and `employeeNumberPrefix` would be
|
|
84
|
+
* deleted by the next unrelated settings write, silently, from a screen that never mentions
|
|
85
|
+
* retention. Extending `HrSettings` instead is the other honest route and is a change to a shared
|
|
86
|
+
* shape; this one keeps the numbers where nothing can quietly drop them.
|
|
87
|
+
*
|
|
88
|
+
* Workspace-wide for now. The honest end state is per legal entity — a Dutch entity and a Turkish
|
|
89
|
+
* one have different obligations, which is exactly why `periods` is already per legal entity — and
|
|
90
|
+
* the row carries a jsonb config so that move is an added column rather than a new table.
|
|
91
|
+
*/
|
|
92
|
+
export const HrRetention = z.object({
|
|
93
|
+
punchDetail: RetentionDays,
|
|
94
|
+
punches: RetentionDays,
|
|
95
|
+
attendanceDays: RetentionDays,
|
|
96
|
+
leave: RetentionDays,
|
|
97
|
+
personHistory: RetentionDays,
|
|
98
|
+
personDocuments: RetentionDays,
|
|
99
|
+
terminatedPeople: RetentionDays,
|
|
100
|
+
sensitiveAccessLog: RetentionDays,
|
|
101
|
+
})
|
|
102
|
+
export type HrRetention = z.infer<typeof HrRetention>
|
|
103
|
+
|
|
104
|
+
/** One class, its horizon, and what has already passed it. */
|
|
105
|
+
export const RetentionClassState = z.object({
|
|
106
|
+
class: RetentionClass,
|
|
107
|
+
/** Null means "kept indefinitely", which is what every class ships as. */
|
|
108
|
+
days: z.number().int().nullable(),
|
|
109
|
+
/**
|
|
110
|
+
* How many rows are already older than the horizon — the dry run, asked for with `withCounts`.
|
|
111
|
+
*
|
|
112
|
+
* Null when no horizon is set (nothing to be past) or when the caller did not ask for counts. A
|
|
113
|
+
* retention screen that cannot say what a sweep would touch before it runs is a data-loss button
|
|
114
|
+
* with no confirmation, so this is the number that belongs beside every horizon on it.
|
|
115
|
+
*/
|
|
116
|
+
dueNow: z.number().int().nullable(),
|
|
117
|
+
})
|
|
118
|
+
export type RetentionClassState = z.infer<typeof RetentionClassState>
|
|
119
|
+
|
|
120
|
+
export const RetentionSettings = z.object({
|
|
121
|
+
...ws,
|
|
122
|
+
classes: z.array(RetentionClassState),
|
|
123
|
+
updatedAt: Timestamp.nullable(),
|
|
124
|
+
updatedBy: z.uuid().nullable(),
|
|
125
|
+
/**
|
|
126
|
+
* Whether anything in HR deletes on these horizons yet. **It is false, and saying so is the
|
|
127
|
+
* point.**
|
|
128
|
+
*
|
|
129
|
+
* The horizons are read in two places today: here, to count what has passed one, and by
|
|
130
|
+
* `privacy.erase`, to say under which horizon each surviving class was kept. No timed sweep acts
|
|
131
|
+
* on them. An unattended job that prunes personnel records is the one act in this module that
|
|
132
|
+
* cannot be undone by re-running anything, so it ships off, with a dry run and a per-run report
|
|
133
|
+
* naming every person it touched — and until it exists this field must not claim otherwise.
|
|
134
|
+
*/
|
|
135
|
+
sweepEnabled: z.literal(false),
|
|
136
|
+
})
|
|
137
|
+
export type RetentionSettings = z.infer<typeof RetentionSettings>
|
|
138
|
+
|
|
139
|
+
// =====================================================================================
|
|
140
|
+
// erasure
|
|
141
|
+
// =====================================================================================
|
|
142
|
+
|
|
143
|
+
/** The units erasure reports in. One per group of columns that survive or go together. */
|
|
144
|
+
export const ErasureClass = z.enum([
|
|
145
|
+
'identity',
|
|
146
|
+
'sensitive',
|
|
147
|
+
'history',
|
|
148
|
+
'documents',
|
|
149
|
+
'punches',
|
|
150
|
+
'attendance',
|
|
151
|
+
'leaveRequests',
|
|
152
|
+
'leaveLedger',
|
|
153
|
+
'employment',
|
|
154
|
+
'officeAssignments',
|
|
155
|
+
'approvals',
|
|
156
|
+
'approvalDecisions',
|
|
157
|
+
'regularizations',
|
|
158
|
+
'delegations',
|
|
159
|
+
'headship',
|
|
160
|
+
])
|
|
161
|
+
export type ErasureClass = z.infer<typeof ErasureClass>
|
|
162
|
+
|
|
163
|
+
/** A class the erasure cleared, and exactly which columns it cleared. */
|
|
164
|
+
export const ErasureRedaction = z.object({
|
|
165
|
+
class: ErasureClass,
|
|
166
|
+
table: z.string(),
|
|
167
|
+
/** Rows that still had something to clear. Zero on a replay, which is what makes it replayable. */
|
|
168
|
+
rows: z.number().int(),
|
|
169
|
+
columns: z.array(z.string()),
|
|
170
|
+
})
|
|
171
|
+
export type ErasureRedaction = z.infer<typeof ErasureRedaction>
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Why a record survived an erasure.
|
|
175
|
+
*
|
|
176
|
+
* An enum rather than a sentence: a reason composed on the server is English for every reader, and
|
|
177
|
+
* this one is read by the person who asked to be forgotten. The client renders it.
|
|
178
|
+
*/
|
|
179
|
+
export const RetentionBasis = z.enum([
|
|
180
|
+
/** A wage, an entitlement or the hours behind one. Statutory wherever Kern ships a country pack. */
|
|
181
|
+
'payRecord',
|
|
182
|
+
/** What changed, when, and who authorised it. Erasing the trail and erasing the data differ. */
|
|
183
|
+
'auditTrail',
|
|
184
|
+
/** The workspace set a horizon for this class and it has not passed. */
|
|
185
|
+
'retentionHorizon',
|
|
186
|
+
/** HR cannot remove it: the bytes belong to another module. See `caveats`. */
|
|
187
|
+
'notRemovable',
|
|
188
|
+
/** It is somebody else's record. Erasing A is not authority to rewrite what A did to B's row. */
|
|
189
|
+
'anotherPersonsRecord',
|
|
190
|
+
])
|
|
191
|
+
export type RetentionBasis = z.infer<typeof RetentionBasis>
|
|
192
|
+
|
|
193
|
+
export const ErasureRetained = z.object({
|
|
194
|
+
class: ErasureClass,
|
|
195
|
+
table: z.string(),
|
|
196
|
+
rows: z.number().int(),
|
|
197
|
+
basis: RetentionBasis,
|
|
198
|
+
/** The horizon in force for the retention class covering this, when the workspace set one. */
|
|
199
|
+
retentionDays: z.number().int().nullable(),
|
|
200
|
+
})
|
|
201
|
+
export type ErasureRetained = z.infer<typeof ErasureRetained>
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Things the response has to state in words, as keys the client renders.
|
|
205
|
+
*
|
|
206
|
+
* Each one is a promise this module would otherwise be making and not keeping.
|
|
207
|
+
*/
|
|
208
|
+
export const ErasureCaveat = z.enum([
|
|
209
|
+
/**
|
|
210
|
+
* `person_documents` rows and their files both survive. `core.files.get` is the only file
|
|
211
|
+
* procedure a module can reach — there is no `files.delete` — so an erasure claiming to remove
|
|
212
|
+
* employee documents would null a row and leave the passport scan in the bucket. The rows stay
|
|
213
|
+
* because they are also the only remaining record of which files those are.
|
|
214
|
+
*/
|
|
215
|
+
'documentFilesRemain',
|
|
216
|
+
/** The profile photo pointer was cleared; the object behind it is in `filesRemaining`. */
|
|
217
|
+
'photoFileOrphaned',
|
|
218
|
+
/** Sick notes attached to leave requests, same reason and same list. */
|
|
219
|
+
'leaveDocumentFilesRemain',
|
|
220
|
+
/** The national identity number was kept, because the caller asked for it to be. */
|
|
221
|
+
'nationalIdKeptForAudit',
|
|
222
|
+
/**
|
|
223
|
+
* History rows where this person was the **actor** on somebody else's record keep their values.
|
|
224
|
+
* Erasing A is not authority to rewrite the trail of what A did to B.
|
|
225
|
+
*/
|
|
226
|
+
'actorHistoryKept',
|
|
227
|
+
/** At least one attendance day or punch sits in a locked period and was left exactly as it is. */
|
|
228
|
+
'lockedPeriodUntouched',
|
|
229
|
+
])
|
|
230
|
+
export type ErasureCaveat = z.infer<typeof ErasureCaveat>
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* What an erasure did, or would do.
|
|
234
|
+
*
|
|
235
|
+
* The same shape either way, because a preview computed differently from the thing it previews is a
|
|
236
|
+
* preview that eventually lies — the rule `accrual.preview` is written under.
|
|
237
|
+
*/
|
|
238
|
+
export const ErasureReport = z.object({
|
|
239
|
+
...ws,
|
|
240
|
+
personId: z.uuid(),
|
|
241
|
+
/** True when nothing was written. See `privacy.erase` for why this is what the default gives you. */
|
|
242
|
+
dryRun: z.boolean(),
|
|
243
|
+
/**
|
|
244
|
+
* When this person was erased. Null on a dry run of a person who has not been.
|
|
245
|
+
*
|
|
246
|
+
* On a replay it carries the **first** erasure's timestamp: the second run finds nothing left to
|
|
247
|
+
* clear, reports zero rows everywhere, and does not restamp the tombstone.
|
|
248
|
+
*/
|
|
249
|
+
erasedAt: Timestamp.nullable(),
|
|
250
|
+
/** The pseudonym the directory shows now. Never a name, never empty — `displayName` is not null. */
|
|
251
|
+
displayName: z.string(),
|
|
252
|
+
redacted: z.array(ErasureRedaction),
|
|
253
|
+
kept: z.array(ErasureRetained),
|
|
254
|
+
caveats: z.array(ErasureCaveat),
|
|
255
|
+
/**
|
|
256
|
+
* File objects in core's storage that this erasure could not delete, and which are recorded on the
|
|
257
|
+
* person row so a later release can finish the job rather than losing them.
|
|
258
|
+
*/
|
|
259
|
+
filesRemaining: z.array(z.uuid()),
|
|
260
|
+
})
|
|
261
|
+
export type ErasureReport = z.infer<typeof ErasureReport>
|
|
262
|
+
|
|
263
|
+
// =====================================================================================
|
|
264
|
+
// the sensitive read log
|
|
265
|
+
// =====================================================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* How a sensitive record was reached.
|
|
269
|
+
*
|
|
270
|
+
* Derived from the principal, never taken from the caller — a client-supplied value is one the
|
|
271
|
+
* reader controls, and this log exists to say what happened rather than what the reader claimed.
|
|
272
|
+
* `export` is the exception and is not reachable from a request: it is passed by the one server-side
|
|
273
|
+
* path that takes a bulk copy of a record, which is `privacy.subjectAccess`.
|
|
274
|
+
*
|
|
275
|
+
* There is no `mcp` and no `service`, and both omissions are the same rule rather than an oversight.
|
|
276
|
+
* Core's MCP proxy forwards a plain bearer token, so an assistant's read arrives as an ordinary
|
|
277
|
+
* `kind: 'user'` principal that nothing here can honestly tell from a browser; and a service
|
|
278
|
+
* principal carries no user id at all, so the read is refused before a row is built. A value nothing
|
|
279
|
+
* can ever write is the same lie as a permission key nothing asks about. See `services/audit.ts`.
|
|
280
|
+
*/
|
|
281
|
+
export const SensitiveAccessVia = z.enum(['ui', 'api', 'export'])
|
|
282
|
+
export type SensitiveAccessVia = z.infer<typeof SensitiveAccessVia>
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* One read of somebody's identity, birth date or bank details.
|
|
286
|
+
*
|
|
287
|
+
* Written in the same transaction as the read, in HR's own schema. `mod_core.activity_events` also
|
|
288
|
+
* gets a best-effort copy so the workspace's single audit console shows that HR sensitive data was
|
|
289
|
+
* touched — but it cannot be the record, for two reasons: it is read behind `core.audit.view`,
|
|
290
|
+
* which owners and admins hold by default and `hr.person.view_sensitive` deliberately nobody does,
|
|
291
|
+
* so publishing the read there inverts the model the sensitive split exists to create; and the
|
|
292
|
+
* cross-service call is allowed to fail, which is right for a notification and wrong for evidence.
|
|
293
|
+
* A subject-access response that answers "who looked at my data" out of a log with silent holes is
|
|
294
|
+
* worse than one that says it cannot answer.
|
|
295
|
+
*/
|
|
296
|
+
export const SensitiveAccess = z.object({
|
|
297
|
+
id: z.uuid(),
|
|
298
|
+
/** Whose record was read. */
|
|
299
|
+
personId: z.uuid(),
|
|
300
|
+
/**
|
|
301
|
+
* The account the disclosure is recorded against. Never null: a reader nobody can be named for is
|
|
302
|
+
* refused before the record is loaded, because "somebody read your bank details" is not an answer
|
|
303
|
+
* to give a subject.
|
|
304
|
+
*/
|
|
305
|
+
actorUserId: z.uuid(),
|
|
306
|
+
/** The reader's own HR record, when they have one. Plenty of accounts are not employees. */
|
|
307
|
+
actorPersonId: z.uuid().nullable(),
|
|
308
|
+
/**
|
|
309
|
+
* Only the fields that actually came back with a value.
|
|
310
|
+
*
|
|
311
|
+
* Logging "read the record" when three of the four were empty overstates what happened, and this
|
|
312
|
+
* log is itself read by the subject.
|
|
313
|
+
*/
|
|
314
|
+
fields: z.array(z.string()),
|
|
315
|
+
/** Why, when the caller said. Shown to the subject in their own bundle — nowhere else. */
|
|
316
|
+
purpose: z.string().max(500).nullable(),
|
|
317
|
+
via: SensitiveAccessVia,
|
|
318
|
+
at: Timestamp,
|
|
319
|
+
})
|
|
320
|
+
export type SensitiveAccess = z.infer<typeof SensitiveAccess>
|
|
321
|
+
|
|
322
|
+
// =====================================================================================
|
|
323
|
+
// the subject-access bundle
|
|
324
|
+
// =====================================================================================
|
|
325
|
+
|
|
326
|
+
/** `person_history`, with the values. Redacting it in a subject's own bundle defeats the purpose. */
|
|
327
|
+
export const PersonHistoryEntry = z.object({
|
|
328
|
+
id: z.uuid(),
|
|
329
|
+
field: z.string(),
|
|
330
|
+
from: z.unknown(),
|
|
331
|
+
to: z.unknown(),
|
|
332
|
+
at: Timestamp,
|
|
333
|
+
actorId: z.uuid().nullable(),
|
|
334
|
+
source: z.string(),
|
|
335
|
+
})
|
|
336
|
+
export type PersonHistoryEntry = z.infer<typeof PersonHistoryEntry>
|
|
337
|
+
|
|
338
|
+
/** A request exploded into days — what the balance was actually charged for. */
|
|
339
|
+
export const LeaveRequestDay = z.object({
|
|
340
|
+
id: z.uuid(),
|
|
341
|
+
requestId: z.uuid(),
|
|
342
|
+
date: IsoDate,
|
|
343
|
+
fraction: z.number(),
|
|
344
|
+
counted: z.boolean(),
|
|
345
|
+
status: z.string(),
|
|
346
|
+
})
|
|
347
|
+
export type LeaveRequestDay = z.infer<typeof LeaveRequestDay>
|
|
348
|
+
|
|
349
|
+
/** A section that hit the row cap. Named, with its numbers, so nothing is short by surprise. */
|
|
350
|
+
export const BundleTruncation = z.object({
|
|
351
|
+
section: z.string(),
|
|
352
|
+
returned: z.number().int(),
|
|
353
|
+
cap: z.number().int(),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/** Something this bundle does not contain, and why. */
|
|
357
|
+
export const BundleExclusion = z.object({
|
|
358
|
+
section: z.string(),
|
|
359
|
+
reason: z.enum([
|
|
360
|
+
/**
|
|
361
|
+
* The bytes of a file. HR holds the metadata and the id; the object lives in core's storage and
|
|
362
|
+
* a module can only ask for a download URL for one file at a time.
|
|
363
|
+
*/
|
|
364
|
+
'fileContentsNotExportable',
|
|
365
|
+
/** The caller asked for the fast path. */
|
|
366
|
+
'notRequested',
|
|
367
|
+
]),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
export const SubjectAccessManifest = z.object({
|
|
371
|
+
...ws,
|
|
372
|
+
personId: z.uuid(),
|
|
373
|
+
generatedAt: Timestamp,
|
|
374
|
+
generatedBy: z.uuid().nullable(),
|
|
375
|
+
/** From `packageVersion(import.meta.url)`, never a literal — the literals drifted for months. */
|
|
376
|
+
moduleVersion: z.string(),
|
|
377
|
+
truncated: z.array(BundleTruncation),
|
|
378
|
+
excluded: z.array(BundleExclusion),
|
|
379
|
+
})
|
|
380
|
+
export type SubjectAccessManifest = z.infer<typeof SubjectAccessManifest>
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Everything HR holds about one person, including the parts they cannot reach through the product.
|
|
384
|
+
*
|
|
385
|
+
* Structured JSON rather than a zip of files, and one person at a time. Both are deliberate:
|
|
386
|
+
*
|
|
387
|
+
* - **The rows are not the problem; documents are.** Five years of four punches a day is about nine
|
|
388
|
+
* thousand rows and two to three megabytes — large for a response and bounded. A contract PDF,
|
|
389
|
+
* three payslips a year and an identity scan has no upper bound at all, which is why the file
|
|
390
|
+
* bytes are named in `manifest.excluded` instead of being streamed through a request handler.
|
|
391
|
+
* Every section is capped and every cap that bites is named in `manifest.truncated`.
|
|
392
|
+
* - **There is no workspace-wide variant, and there will not be.** Five hundred people is two and a
|
|
393
|
+
* half million rows with decrypted bank details in flight. `personId` is required and there is no
|
|
394
|
+
* filter.
|
|
395
|
+
*
|
|
396
|
+
* `sensitive` is decrypted here, which makes this the most dangerous object the module produces —
|
|
397
|
+
* so producing it writes a `sensitive_access_log` row with `via: 'export'` before it is returned. An
|
|
398
|
+
* export is a bulk read of that record, not a smaller one.
|
|
399
|
+
*/
|
|
400
|
+
export const SubjectAccessBundle = z.object({
|
|
401
|
+
manifest: SubjectAccessManifest,
|
|
402
|
+
/**
|
|
403
|
+
* The whole row, not the directory card.
|
|
404
|
+
*
|
|
405
|
+
* `forViewer` narrows `personalEmail`, `phone`, `hiredOn` and `terminatedOn` for a reader who may
|
|
406
|
+
* not see somebody's personnel record. Those four are the subject's own; they are not narrowed
|
|
407
|
+
* here.
|
|
408
|
+
*/
|
|
409
|
+
person: Person,
|
|
410
|
+
sensitive: PersonSensitive,
|
|
411
|
+
employment: z.array(Employment),
|
|
412
|
+
offices: z.array(OfficeAssignment),
|
|
413
|
+
history: z.array(PersonHistoryEntry),
|
|
414
|
+
/** Metadata and file ids. The bytes are named in `manifest.excluded`. */
|
|
415
|
+
documents: z.array(PersonDocument),
|
|
416
|
+
leave: z.object({
|
|
417
|
+
types: z.array(LeaveType),
|
|
418
|
+
requests: z.array(LeaveRequest),
|
|
419
|
+
days: z.array(LeaveRequestDay),
|
|
420
|
+
ledger: z.array(LeaveLedgerEntry),
|
|
421
|
+
/** The running balance down the ledger, so "why is my balance this number" is answered here. */
|
|
422
|
+
closingBalanceMinutes: z.number().int(),
|
|
423
|
+
}),
|
|
424
|
+
attendance: z.object({
|
|
425
|
+
punches: z.array(Punch),
|
|
426
|
+
days: z.array(AttendanceDay),
|
|
427
|
+
}),
|
|
428
|
+
regularizations: z.array(Regularization),
|
|
429
|
+
approvals: z.object({
|
|
430
|
+
/** Requests this person raised. */
|
|
431
|
+
raised: z.array(ApprovalRequest),
|
|
432
|
+
/** Steps they were named on as an approver, with the decisions filed against them. */
|
|
433
|
+
approverOn: z.array(ApprovalStep),
|
|
434
|
+
/** Their own decisions, wherever they were filed. */
|
|
435
|
+
decisions: z.array(ApprovalDecision),
|
|
436
|
+
}),
|
|
437
|
+
/** Both directions: what they handed over, and what was handed to them. */
|
|
438
|
+
delegations: z.object({
|
|
439
|
+
given: z.array(Delegation),
|
|
440
|
+
received: z.array(Delegation),
|
|
441
|
+
}),
|
|
442
|
+
/**
|
|
443
|
+
* The accrual, overtime and rounding policy resolved for this person today, with the rung that
|
|
444
|
+
* answered. Included whatever the `leave_accrual` capability is set to: it is the subject's own
|
|
445
|
+
* data, and "why does she accrue differently from her team" is the commonest follow-up a subject
|
|
446
|
+
* access request produces.
|
|
447
|
+
*/
|
|
448
|
+
policiesInForce: z.array(ResolvedPolicy),
|
|
449
|
+
/** Who read their identity, birth date or bank details. A bundle without it is incomplete. */
|
|
450
|
+
accessLog: z.array(SensitiveAccess),
|
|
451
|
+
})
|
|
452
|
+
export type SubjectAccessBundle = z.infer<typeof SubjectAccessBundle>
|
package/src/contract/router.ts
CHANGED
|
@@ -62,6 +62,13 @@ import {
|
|
|
62
62
|
PolicySubjectKind,
|
|
63
63
|
ResolvedPolicy,
|
|
64
64
|
} from './policies.js'
|
|
65
|
+
import {
|
|
66
|
+
ErasureReport,
|
|
67
|
+
HrRetention,
|
|
68
|
+
RetentionSettings,
|
|
69
|
+
SensitiveAccess,
|
|
70
|
+
SubjectAccessBundle,
|
|
71
|
+
} from './privacy.js'
|
|
65
72
|
|
|
66
73
|
const ws = z.object({ workspaceId: WorkspaceId })
|
|
67
74
|
const t = ['hr'] as const
|
|
@@ -1259,5 +1266,119 @@ export const hrContract = {
|
|
|
1259
1266
|
.input(ws.extend({ fieldId: z.uuid() }))
|
|
1260
1267
|
.output(ok),
|
|
1261
1268
|
},
|
|
1269
|
+
|
|
1270
|
+
// ---------------------------------------------------------------- privacy
|
|
1271
|
+
/**
|
|
1272
|
+
* Subject access, erasure and retention. All four behind `hr.privacy.manage`, which is granted to
|
|
1273
|
+
* nobody by default and ships in the same change as these — see `privacy.ts` for why there is one
|
|
1274
|
+
* key rather than four, and why none of this is a capability.
|
|
1275
|
+
*/
|
|
1276
|
+
privacy: {
|
|
1277
|
+
/**
|
|
1278
|
+
* Everything HR holds about one person, in one response.
|
|
1279
|
+
*
|
|
1280
|
+
* `personId` is required and there is no filter: a workspace-wide export is two and a half
|
|
1281
|
+
* million rows with decrypted bank details in flight, and it is not offered.
|
|
1282
|
+
*
|
|
1283
|
+
* The decrypt makes this a bulk read of the sensitive record, so it writes a
|
|
1284
|
+
* `sensitive_access_log` row with `via: 'export'` in the same transaction.
|
|
1285
|
+
*/
|
|
1286
|
+
subjectAccess: baseContract
|
|
1287
|
+
.route({ method: 'GET', path: '/people/{personId}/privacy/subject-access', tags: t })
|
|
1288
|
+
.input(
|
|
1289
|
+
ws.extend({
|
|
1290
|
+
personId: z.uuid(),
|
|
1291
|
+
/** Recorded on the access-log row and shown to the subject in their own bundle. */
|
|
1292
|
+
purpose: z.string().max(500).nullish(),
|
|
1293
|
+
}),
|
|
1294
|
+
)
|
|
1295
|
+
.output(SubjectAccessBundle),
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Redact a person, and say what survived.
|
|
1299
|
+
*
|
|
1300
|
+
* **`dryRun` defaults to true**, which is the opposite of every other write in this module and
|
|
1301
|
+
* deliberate. This is the one irreversible act HR offers, so a caller that forgets the flag has
|
|
1302
|
+
* to get the preview rather than the erasure. It matters more than it reads: core generates an
|
|
1303
|
+
* MCP tool from every hosted module's OpenAPI document, so a procedure with a REST route is
|
|
1304
|
+
* agent-callable the day it ships, and the call made with no arguments has to be the harmless
|
|
1305
|
+
* one. The preview runs the same predicates the run does and returns the same shape.
|
|
1306
|
+
*
|
|
1307
|
+
* Nothing is deleted. The identifying columns are cleared and every record a wage, an
|
|
1308
|
+
* entitlement or an authorisation was computed from stays, listed in `kept` with the basis it
|
|
1309
|
+
* survived under. Running it twice is safe and reports zero rows the second time: each step
|
|
1310
|
+
* matches only rows that still have something to clear.
|
|
1311
|
+
*/
|
|
1312
|
+
erase: baseContract
|
|
1313
|
+
.route({ method: 'POST', path: '/people/{personId}/privacy/erase', tags: t })
|
|
1314
|
+
.input(
|
|
1315
|
+
ws.extend({
|
|
1316
|
+
personId: z.uuid(),
|
|
1317
|
+
dryRun: z.boolean().default(true),
|
|
1318
|
+
/** Recorded on the person row. The only place this reason is kept. */
|
|
1319
|
+
reason: z.string().max(500).nullish(),
|
|
1320
|
+
/**
|
|
1321
|
+
* Keep the national identity number.
|
|
1322
|
+
*
|
|
1323
|
+
* The one decision in here that is not the module's to make. A Turkish payroll audit may
|
|
1324
|
+
* want it; a GDPR erasure request wants it gone first. The default clears it, the response
|
|
1325
|
+
* says which was done, and `caveats` carries `nationalIdKeptForAudit` when it did not.
|
|
1326
|
+
*/
|
|
1327
|
+
keepNationalIdForAudit: z.boolean().default(false),
|
|
1328
|
+
}),
|
|
1329
|
+
)
|
|
1330
|
+
.output(ErasureReport),
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Who read somebody's identity, birth date or bank details.
|
|
1334
|
+
*
|
|
1335
|
+
* **About yourself this carries no permission**, which is why it has no `requires()` and sits in
|
|
1336
|
+
* `module.test.ts`'s `SELF_SERVICE` allowlist. Reading your own access log is a thing nobody may
|
|
1337
|
+
* lack, exactly like `people.me`; a grantable key here could only ever be one somebody could be
|
|
1338
|
+
* denied, and being denied sight of who has been looking at your bank details is not a state
|
|
1339
|
+
* this product should be able to express.
|
|
1340
|
+
*
|
|
1341
|
+
* About anybody else it needs `hr.privacy.manage`, and the handler is what asks — a `personId`
|
|
1342
|
+
* that is not the caller's own, or an `actorUserId` at all. The second is not a smaller question
|
|
1343
|
+
* than the first: "what has this account been looking at" is an investigation into a colleague,
|
|
1344
|
+
* and it is the query that makes this log something to be careful with rather than only
|
|
1345
|
+
* something to be reassured by.
|
|
1346
|
+
*/
|
|
1347
|
+
accessLog: {
|
|
1348
|
+
list: baseContract
|
|
1349
|
+
.route({ method: 'GET', path: '/privacy/access-log', tags: t })
|
|
1350
|
+
.input(
|
|
1351
|
+
ws.extend({
|
|
1352
|
+
...PageInput.shape,
|
|
1353
|
+
/** Whose record. Defaults to the caller's own, which is the self-service case. */
|
|
1354
|
+
personId: z.uuid().optional(),
|
|
1355
|
+
/** Whose reading. Always an investigation, so always `hr.privacy.manage`. */
|
|
1356
|
+
actorUserId: z.uuid().optional(),
|
|
1357
|
+
}),
|
|
1358
|
+
)
|
|
1359
|
+
.output(page(SensitiveAccess)),
|
|
1360
|
+
},
|
|
1361
|
+
|
|
1362
|
+
retention: {
|
|
1363
|
+
/**
|
|
1364
|
+
* The horizons, and — with `withCounts` — how much is already past each one.
|
|
1365
|
+
*
|
|
1366
|
+
* The counts are the dry run. They cost a query per class that has a horizon set, so they are
|
|
1367
|
+
* asked for rather than always computed; a retention screen should ask for them.
|
|
1368
|
+
*/
|
|
1369
|
+
get: baseContract
|
|
1370
|
+
.route({ method: 'GET', path: '/privacy/retention', tags: t })
|
|
1371
|
+
.input(ws.extend({ withCounts: z.boolean().default(false) }))
|
|
1372
|
+
.output(RetentionSettings),
|
|
1373
|
+
/**
|
|
1374
|
+
* Set them. A field left out is unchanged; a field sent as `null` goes back to "keep
|
|
1375
|
+
* indefinitely", which is what every class ships as.
|
|
1376
|
+
*/
|
|
1377
|
+
set: baseContract
|
|
1378
|
+
.route({ method: 'PUT', path: '/privacy/retention', tags: t })
|
|
1379
|
+
.input(ws.extend({ retention: HrRetention.partial() }))
|
|
1380
|
+
.output(RetentionSettings),
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1262
1383
|
}
|
|
1263
1384
|
export type HrContract = typeof hrContract
|