@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.280.0
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/package.json +9 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
|
@@ -20,7 +20,11 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
|
|
|
20
20
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
21
|
import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
22
22
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
23
|
-
import
|
|
23
|
+
import {
|
|
24
|
+
createSystemUser,
|
|
25
|
+
type JobContext,
|
|
26
|
+
SYSTEM_TENANT_ID,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
28
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
25
29
|
import {
|
|
26
30
|
setupTestStack,
|
|
@@ -50,7 +54,7 @@ let stack: TestStack;
|
|
|
50
54
|
|
|
51
55
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
52
56
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
53
|
-
const TENANT_SYSTEM =
|
|
57
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
54
58
|
|
|
55
59
|
// Deterministische UUIDs fuer Tests — gleiche Helper wie in
|
|
56
60
|
// user-data-rights-defaults.
|
|
@@ -318,6 +322,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
318
322
|
}),
|
|
319
323
|
registry: stack.registry,
|
|
320
324
|
log,
|
|
325
|
+
systemUser: createSystemUser(TENANT_A),
|
|
321
326
|
};
|
|
322
327
|
await job?.handler({}, jobCtx as unknown as JobContext);
|
|
323
328
|
|
|
@@ -359,6 +364,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
359
364
|
unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
|
|
360
365
|
}),
|
|
361
366
|
registry: stack.registry,
|
|
367
|
+
systemUser: createSystemUser(TENANT_A),
|
|
362
368
|
log,
|
|
363
369
|
} as unknown as JobContext);
|
|
364
370
|
} finally {
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
isPiiCiphertext,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
27
27
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createSystemUser,
|
|
30
|
+
SYSTEM_TENANT_ID,
|
|
31
|
+
type UserDataHookCtx,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
33
|
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
30
34
|
import {
|
|
31
35
|
setupTestStack,
|
|
@@ -58,7 +62,7 @@ let stack: TestStack;
|
|
|
58
62
|
|
|
59
63
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
60
64
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
61
|
-
const TENANT_SYSTEM =
|
|
65
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
62
66
|
|
|
63
67
|
function uuid(suffix: number): string {
|
|
64
68
|
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
@@ -498,7 +502,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
498
502
|
// in the GDPR bundle. The hook decrypts locally (same as invitations
|
|
499
503
|
// query) — assert plaintext here so a regression to raw ciphertext fails.
|
|
500
504
|
const hookCtx: UserDataHookCtx = {
|
|
501
|
-
db: stack.db,
|
|
505
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
502
506
|
registry: stack.registry,
|
|
503
507
|
tenantId: TENANT_A,
|
|
504
508
|
userId: ALICE_ID,
|
|
@@ -571,7 +575,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
|
|
|
571
575
|
// assertion would catch it even though the runUserExport() assertions
|
|
572
576
|
// below would stay green either way.
|
|
573
577
|
const hookCtx: UserDataHookCtx = {
|
|
574
|
-
db: stack.db,
|
|
578
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
575
579
|
registry: stack.registry,
|
|
576
580
|
tenantId: TENANT_A,
|
|
577
581
|
userId: ALICE_ID,
|
|
@@ -65,11 +65,6 @@ const TABLE = "read_dsgvo_tenant_scoped";
|
|
|
65
65
|
|
|
66
66
|
// Tenant-scoped contributor with NO per-user column — deletes by tenant only,
|
|
67
67
|
// and ONLY when this tenant is effectively single-user (mirrors credit).
|
|
68
|
-
const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
69
|
-
if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
|
|
70
|
-
await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
68
|
const scopedEntity = createEntity({
|
|
74
69
|
table: TABLE,
|
|
75
70
|
fields: {
|
|
@@ -77,11 +72,26 @@ const scopedEntity = createEntity({
|
|
|
77
72
|
},
|
|
78
73
|
});
|
|
79
74
|
|
|
75
|
+
// fw#2914 — "tenant-scoped" is a managed r.entity table (EXECUTOR_ONLY-branded),
|
|
76
|
+
// so TenantDb's typed write methods reject it; this delete is raw SQL, still
|
|
77
|
+
// filtered by tenant_id.
|
|
78
|
+
const TENANT_SCOPED_DELETE_REASON =
|
|
79
|
+
"fw#2914 test fixture: tenant-scoped erasure against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by tenant_id";
|
|
80
|
+
|
|
81
|
+
const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
82
|
+
if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
|
|
83
|
+
await asRawClient(ctx.db.unsafeRaw(TENANT_SCOPED_DELETE_REASON)).unsafe(
|
|
84
|
+
`DELETE FROM ${TABLE} WHERE tenant_id = $1`,
|
|
85
|
+
[ctx.tenantId],
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
80
89
|
const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
|
|
81
90
|
r.entity("tenant-scoped", scopedEntity);
|
|
82
91
|
r.useExtension(EXT_USER_DATA, "tenant-scoped", {
|
|
83
92
|
export: async () => null,
|
|
84
93
|
delete: tenantScopedDeleteHook,
|
|
94
|
+
escapeHatch: { reason: TENANT_SCOPED_DELETE_REASON },
|
|
85
95
|
});
|
|
86
96
|
});
|
|
87
97
|
|
|
@@ -610,6 +610,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
610
610
|
...(opts.appExportDownloadUrl !== undefined && {
|
|
611
611
|
appExportDownloadUrl: opts.appExportDownloadUrl,
|
|
612
612
|
}),
|
|
613
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
614
|
+
actor: ctx.systemUser.id,
|
|
613
615
|
});
|
|
614
616
|
},
|
|
615
617
|
});
|
|
@@ -677,6 +679,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
677
679
|
handlerName: "user-data-rights:run-forget-cleanup",
|
|
678
680
|
}),
|
|
679
681
|
...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
|
|
682
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
683
|
+
actor: ctx.systemUser.id,
|
|
680
684
|
});
|
|
681
685
|
|
|
682
686
|
// Persist each incomplete report on the job-run log (#1572). The write
|
|
@@ -49,7 +49,11 @@ import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
|
49
49
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
50
50
|
import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
51
51
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
52
|
-
import type {
|
|
52
|
+
import type {
|
|
53
|
+
EscapeHatchAuditSink,
|
|
54
|
+
Registry,
|
|
55
|
+
TenantId,
|
|
56
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
53
57
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
54
58
|
import {
|
|
55
59
|
createZipStream,
|
|
@@ -155,6 +159,11 @@ export interface RunExportJobsArgs {
|
|
|
155
159
|
* a URL fragment (not a query param — see issue #1271) and passes that
|
|
156
160
|
* Callback durch. Required wenn sendExportReadyEmail gesetzt. */
|
|
157
161
|
readonly appExportDownloadUrl?: string;
|
|
162
|
+
|
|
163
|
+
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
164
|
+
// systemUser.id); threaded into runUserExport's per-hook TenantDb.
|
|
165
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
166
|
+
readonly actor?: string;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
export interface ExportJobError {
|
|
@@ -219,6 +228,8 @@ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJ
|
|
|
219
228
|
buildStorageProvider,
|
|
220
229
|
now,
|
|
221
230
|
job,
|
|
231
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
232
|
+
actor: args.actor,
|
|
222
233
|
});
|
|
223
234
|
if (outcome.kind === "done") {
|
|
224
235
|
completedJobIds.push(job.id);
|
|
@@ -337,6 +348,8 @@ async function processJob(args: {
|
|
|
337
348
|
buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
|
|
338
349
|
now: Instant;
|
|
339
350
|
job: JobRow;
|
|
351
|
+
escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
352
|
+
actor?: string;
|
|
340
353
|
}): Promise<ProcessOutcome> {
|
|
341
354
|
const { db, registry, buildStorageProvider, now, job } = args;
|
|
342
355
|
const executor = createSystemUser(job.requestedFromTenantId);
|
|
@@ -381,6 +394,8 @@ async function processJob(args: {
|
|
|
381
394
|
registry,
|
|
382
395
|
userId: job.userId,
|
|
383
396
|
now,
|
|
397
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
398
|
+
actor: args.actor,
|
|
384
399
|
});
|
|
385
400
|
|
|
386
401
|
const profile = await resolveProfileForTenant({
|
|
@@ -39,14 +39,17 @@ import {
|
|
|
39
39
|
subjectIdToKey,
|
|
40
40
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
41
41
|
import {
|
|
42
|
+
createTenantDb,
|
|
42
43
|
type DbRunner,
|
|
43
44
|
entityEventName,
|
|
44
45
|
executeRawQuery,
|
|
45
46
|
nullBlindIndexesForSubject,
|
|
46
47
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
47
48
|
import {
|
|
49
|
+
type EscapeHatchAuditSink,
|
|
48
50
|
EXT_USER_DATA,
|
|
49
51
|
EXT_USER_DATA_ORDER,
|
|
52
|
+
extensionUsageEscapeHatchReason,
|
|
50
53
|
type Registry,
|
|
51
54
|
type TenantId,
|
|
52
55
|
type TenantUserModel,
|
|
@@ -54,6 +57,7 @@ import {
|
|
|
54
57
|
type UserDataDeleteStrategy,
|
|
55
58
|
type UserDataStorageProvider,
|
|
56
59
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
57
61
|
import {
|
|
58
62
|
purgeSearchDocumentsForSubject,
|
|
59
63
|
type SearchAdapter,
|
|
@@ -140,6 +144,12 @@ export interface RunForgetCleanupArgs {
|
|
|
140
144
|
* (no tenant-scoped erasure).
|
|
141
145
|
*/
|
|
142
146
|
readonly tenantModel?: TenantUserModel;
|
|
147
|
+
|
|
148
|
+
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
149
|
+
// systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
|
|
150
|
+
// escapeHatch when its delete hook calls ctx.db.unsafeRaw(reason).
|
|
151
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
152
|
+
readonly actor?: string;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
export interface ForgetCleanupError {
|
|
@@ -184,6 +194,26 @@ interface HookEntry {
|
|
|
184
194
|
/** Lower runs first. Owner-column-preserving redaction declares a negative
|
|
185
195
|
* order so it precedes owner-nulling hooks on the same entity (see sort below). */
|
|
186
196
|
readonly order: number;
|
|
197
|
+
readonly escapeHatchReason: string | undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildHookDb(
|
|
201
|
+
db: DbRunner,
|
|
202
|
+
tenantId: TenantId,
|
|
203
|
+
entry: HookEntry,
|
|
204
|
+
args: { readonly escapeHatchAuditSink?: EscapeHatchAuditSink; readonly actor?: string },
|
|
205
|
+
) {
|
|
206
|
+
const report = createEscapeHatchReporter({
|
|
207
|
+
handler: `${EXT_USER_DATA}:${entry.entityName}`,
|
|
208
|
+
tenantId,
|
|
209
|
+
actor: args.actor ?? "system",
|
|
210
|
+
sink: args.escapeHatchAuditSink,
|
|
211
|
+
});
|
|
212
|
+
return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
|
|
213
|
+
unsafeRaw:
|
|
214
|
+
entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
|
|
215
|
+
report,
|
|
216
|
+
});
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
// EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
|
|
@@ -226,7 +256,12 @@ export async function runForgetCleanup(
|
|
|
226
256
|
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
|
|
227
257
|
if (!opts.delete) return null;
|
|
228
258
|
const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
|
|
229
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
entityName: u.entityName,
|
|
261
|
+
deleteHook: opts.delete,
|
|
262
|
+
order,
|
|
263
|
+
escapeHatchReason: extensionUsageEscapeHatchReason(u),
|
|
264
|
+
};
|
|
230
265
|
})
|
|
231
266
|
.filter((x): x is HookEntry => x !== null)
|
|
232
267
|
// Order ascending. Array.sort is ES2019-stable, so equal orders keep
|
|
@@ -252,6 +287,8 @@ export async function runForgetCleanup(
|
|
|
252
287
|
appTenantModel,
|
|
253
288
|
kms,
|
|
254
289
|
searchAdapter: args.searchAdapter,
|
|
290
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
291
|
+
actor: args.actor,
|
|
255
292
|
});
|
|
256
293
|
hookCallsAttempted += userResult.hookCallsAttempted;
|
|
257
294
|
errors.push(...userResult.errors);
|
|
@@ -316,6 +353,8 @@ async function processUser(args: {
|
|
|
316
353
|
appTenantModel: TenantUserModel;
|
|
317
354
|
kms?: KmsAdapter;
|
|
318
355
|
searchAdapter?: SearchAdapter;
|
|
356
|
+
escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
357
|
+
actor?: string;
|
|
319
358
|
}): Promise<ProcessUserResult> {
|
|
320
359
|
const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
|
|
321
360
|
const errors: ForgetCleanupError[] = [];
|
|
@@ -404,7 +443,10 @@ async function processUser(args: {
|
|
|
404
443
|
hookCallsAttempted++;
|
|
405
444
|
const hookResult = await entry.deleteHook(
|
|
406
445
|
{
|
|
407
|
-
db: tx,
|
|
446
|
+
db: buildHookDb(tx, tenantId, entry, {
|
|
447
|
+
escapeHatchAuditSink: args.escapeHatchAuditSink,
|
|
448
|
+
actor: args.actor,
|
|
449
|
+
}),
|
|
408
450
|
registry,
|
|
409
451
|
tenantId,
|
|
410
452
|
userId,
|
|
@@ -30,15 +30,19 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
|
30
30
|
import {
|
|
31
31
|
collectEncryptedFieldNames,
|
|
32
32
|
configuredEntityFieldEncryption,
|
|
33
|
+
createTenantDb,
|
|
33
34
|
decryptEntityFieldValues,
|
|
34
35
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
35
36
|
import {
|
|
37
|
+
type EscapeHatchAuditSink,
|
|
36
38
|
EXT_USER_DATA,
|
|
39
|
+
extensionUsageEscapeHatchReason,
|
|
37
40
|
type Registry,
|
|
38
41
|
type TenantId,
|
|
39
42
|
type UserDataExportHook,
|
|
40
43
|
type UserDataExportSnippet,
|
|
41
44
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
45
|
+
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
42
46
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
43
47
|
import { tenantMembershipsTable } from "../tenant";
|
|
44
48
|
import { buildFileRefZipPath } from "./zip-path";
|
|
@@ -50,6 +54,11 @@ export interface RunUserExportArgs {
|
|
|
50
54
|
readonly registry: Registry;
|
|
51
55
|
readonly userId: string;
|
|
52
56
|
readonly now: Instant;
|
|
57
|
+
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
58
|
+
// systemUser.id); attributes+audits any EXT_USER_DATA usage's declared
|
|
59
|
+
// escapeHatch when its export hook calls ctx.db.unsafeRaw(reason).
|
|
60
|
+
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
61
|
+
readonly actor?: string;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export interface UserExportFileRef {
|
|
@@ -90,6 +99,26 @@ export interface UserExportBundle {
|
|
|
90
99
|
interface HookEntry {
|
|
91
100
|
readonly entityName: string;
|
|
92
101
|
readonly exportHook: UserDataExportHook;
|
|
102
|
+
readonly escapeHatchReason: string | undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildHookDb(
|
|
106
|
+
db: DbRunner,
|
|
107
|
+
tenantId: TenantId,
|
|
108
|
+
entry: HookEntry,
|
|
109
|
+
args: Pick<RunUserExportArgs, "escapeHatchAuditSink" | "actor">,
|
|
110
|
+
) {
|
|
111
|
+
const report = createEscapeHatchReporter({
|
|
112
|
+
handler: `${EXT_USER_DATA}:${entry.entityName}`,
|
|
113
|
+
tenantId,
|
|
114
|
+
actor: args.actor ?? "system",
|
|
115
|
+
sink: args.escapeHatchAuditSink,
|
|
116
|
+
});
|
|
117
|
+
return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
|
|
118
|
+
unsafeRaw:
|
|
119
|
+
entry.escapeHatchReason !== undefined ? { reason: entry.escapeHatchReason } : undefined,
|
|
120
|
+
report,
|
|
121
|
+
});
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
/**
|
|
@@ -137,7 +166,13 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
137
166
|
const hookEntries: HookEntry[] = usages
|
|
138
167
|
.map((u): HookEntry | null => {
|
|
139
168
|
const opts = (u.options ?? {}) as { export?: UserDataExportHook }; // @cast-boundary engine-payload
|
|
140
|
-
return opts.export
|
|
169
|
+
return opts.export
|
|
170
|
+
? {
|
|
171
|
+
entityName: u.entityName,
|
|
172
|
+
exportHook: opts.export,
|
|
173
|
+
escapeHatchReason: extensionUsageEscapeHatchReason(u),
|
|
174
|
+
}
|
|
175
|
+
: null;
|
|
141
176
|
})
|
|
142
177
|
.filter((x): x is HookEntry => x !== null);
|
|
143
178
|
|
|
@@ -147,7 +182,12 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
147
182
|
for (const tenantId of tenantList) {
|
|
148
183
|
const entities: UserDataExportSnippet[] = [];
|
|
149
184
|
for (const entry of hookEntries) {
|
|
150
|
-
const rawSnippet = await entry.exportHook({
|
|
185
|
+
const rawSnippet = await entry.exportHook({
|
|
186
|
+
db: buildHookDb(db, tenantId, entry, args),
|
|
187
|
+
registry,
|
|
188
|
+
tenantId,
|
|
189
|
+
userId,
|
|
190
|
+
});
|
|
151
191
|
if (rawSnippet === null) continue;
|
|
152
192
|
const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
|
|
153
193
|
entities.push(snippet);
|
|
@@ -181,7 +221,7 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
181
221
|
const orphanEntities: UserDataExportSnippet[] = [];
|
|
182
222
|
for (const entry of hookEntries) {
|
|
183
223
|
const rawSnippet = await entry.exportHook({
|
|
184
|
-
db,
|
|
224
|
+
db: buildHookDb(db, SYSTEM_TENANT_ID_FOR_ORPHANS, entry, args),
|
|
185
225
|
registry,
|
|
186
226
|
tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
|
|
187
227
|
userId,
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
14
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
17
|
import {
|
|
16
18
|
setupTestStack,
|
|
17
19
|
type TestStack,
|
|
@@ -117,7 +119,7 @@ function uuid(suffix: number): string {
|
|
|
117
119
|
|
|
118
120
|
function ctx(userId: string, overrides: Record<string, unknown> = {}) {
|
|
119
121
|
return {
|
|
120
|
-
db: full.db,
|
|
122
|
+
db: createTenantDb(full.db, TENANT_A, "tenant"),
|
|
121
123
|
registry: full.registry,
|
|
122
124
|
tenantId: TENANT_A,
|
|
123
125
|
userId,
|
|
@@ -132,10 +134,9 @@ async function rawSelect(sql: string, params: unknown[]) {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
async function seedUser(id: string): Promise<void> {
|
|
135
|
-
const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
136
137
|
await seedRow(full.db, userTable, {
|
|
137
138
|
id,
|
|
138
|
-
tenantId:
|
|
139
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
139
140
|
email: `user-${id}@example.com`,
|
|
140
141
|
passwordHash: "hashed-password",
|
|
141
142
|
displayName: `User ${id}`,
|
|
@@ -404,11 +405,11 @@ describe("job-run userData-hooks (#799)", () => {
|
|
|
404
405
|
`INSERT INTO store_job_runs
|
|
405
406
|
(id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
|
|
406
407
|
VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
|
|
407
|
-
[id,
|
|
408
|
+
[id, SYSTEM_TENANT_ID, `bull-${id}`, triggeredById],
|
|
408
409
|
);
|
|
409
410
|
}
|
|
410
411
|
|
|
411
|
-
test("export filters by triggeredById only (
|
|
412
|
+
test("export filters by triggeredById only (rows live on SYSTEM_TENANT_ID, ctx bound to TENANT_A)", async () => {
|
|
412
413
|
await seedRun(uuid(70), "jr-user-1");
|
|
413
414
|
await seedRun(uuid(71), "jr-user-2");
|
|
414
415
|
await seedRun(uuid(72), null);
|
|
@@ -428,7 +429,7 @@ describe("job-run userData-hooks (#799)", () => {
|
|
|
428
429
|
describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
|
|
429
430
|
test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
|
|
430
431
|
const minCtx = {
|
|
431
|
-
db: minimal.db,
|
|
432
|
+
db: createTenantDb(minimal.db, TENANT_A, "tenant"),
|
|
432
433
|
registry: minimal.registry,
|
|
433
434
|
tenantId: TENANT_A,
|
|
434
435
|
userId: "any-user",
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
14
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
17
|
import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
18
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
19
|
import {
|
|
18
20
|
createInMemoryFileProvider,
|
|
19
21
|
deriveKey,
|
|
@@ -87,10 +89,9 @@ async function seedUser(id: string, overrides: Record<string, unknown> = {}): Pr
|
|
|
87
89
|
// user-Entity ist tenant-agnostisch im Domain-Sinn, aber das DB-
|
|
88
90
|
// Schema hat tenant_id-Spalte automatisch (Framework-Default).
|
|
89
91
|
// Pragmatisch: SYSTEM_TENANT_ID fuer User-Rows in Tests.
|
|
90
|
-
const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
91
92
|
await seedRow(stack.db, userTable, {
|
|
92
93
|
id,
|
|
93
|
-
tenantId:
|
|
94
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
94
95
|
email: `user-${id}@example.com`,
|
|
95
96
|
passwordHash: "hashed-password",
|
|
96
97
|
displayName: `User ${id}`,
|
|
@@ -168,7 +169,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
168
169
|
await seedUser(uuid(1001), { displayName: "Marc" });
|
|
169
170
|
|
|
170
171
|
const result = await userExportHook({
|
|
171
|
-
db: stack.db,
|
|
172
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
172
173
|
registry: stack.registry,
|
|
173
174
|
tenantId: TENANT_A,
|
|
174
175
|
userId: uuid(1001),
|
|
@@ -189,7 +190,7 @@ describe("S2.H1 :: userExportHook", () => {
|
|
|
189
190
|
|
|
190
191
|
test("returns null wenn User nicht existiert", async () => {
|
|
191
192
|
const result = await userExportHook({
|
|
192
|
-
db: stack.db,
|
|
193
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
193
194
|
registry: stack.registry,
|
|
194
195
|
tenantId: TENANT_A,
|
|
195
196
|
userId: uuid(1002),
|
|
@@ -203,7 +204,12 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
203
204
|
await seedUser(uuid(1003));
|
|
204
205
|
|
|
205
206
|
await userDeleteHook(
|
|
206
|
-
{
|
|
207
|
+
{
|
|
208
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
209
|
+
registry: stack.registry,
|
|
210
|
+
tenantId: TENANT_A,
|
|
211
|
+
userId: uuid(1003),
|
|
212
|
+
},
|
|
207
213
|
"delete",
|
|
208
214
|
);
|
|
209
215
|
|
|
@@ -222,7 +228,12 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
222
228
|
await seedUser(uuid(1004));
|
|
223
229
|
|
|
224
230
|
await userDeleteHook(
|
|
225
|
-
{
|
|
231
|
+
{
|
|
232
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
233
|
+
registry: stack.registry,
|
|
234
|
+
tenantId: TENANT_A,
|
|
235
|
+
userId: uuid(1004),
|
|
236
|
+
},
|
|
226
237
|
"anonymize",
|
|
227
238
|
);
|
|
228
239
|
|
|
@@ -238,7 +249,12 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
238
249
|
await seedUser(uuid(1005));
|
|
239
250
|
|
|
240
251
|
await userDeleteHook(
|
|
241
|
-
{
|
|
252
|
+
{
|
|
253
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
254
|
+
registry: stack.registry,
|
|
255
|
+
tenantId: TENANT_A,
|
|
256
|
+
userId: uuid(1005),
|
|
257
|
+
},
|
|
242
258
|
"delete",
|
|
243
259
|
);
|
|
244
260
|
const afterFirst = await fetchUser(uuid(1005));
|
|
@@ -247,7 +263,12 @@ describe("S2.H1 :: userDeleteHook", () => {
|
|
|
247
263
|
// Zweiter Call: kein Crash + State unverändert
|
|
248
264
|
await expect(
|
|
249
265
|
userDeleteHook(
|
|
250
|
-
{
|
|
266
|
+
{
|
|
267
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
268
|
+
registry: stack.registry,
|
|
269
|
+
tenantId: TENANT_A,
|
|
270
|
+
userId: uuid(1005),
|
|
271
|
+
},
|
|
251
272
|
"delete",
|
|
252
273
|
),
|
|
253
274
|
).resolves.toBeUndefined();
|
|
@@ -269,7 +290,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
269
290
|
await seedFileRef(uuid(102), TENANT_A, "user-files-1", "anschreiben.pdf");
|
|
270
291
|
|
|
271
292
|
const result = await fileRefExportHook({
|
|
272
|
-
db: stack.db,
|
|
293
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
273
294
|
registry: stack.registry,
|
|
274
295
|
tenantId: TENANT_A,
|
|
275
296
|
userId: "user-files-1",
|
|
@@ -284,7 +305,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
|
|
|
284
305
|
|
|
285
306
|
test("returns null wenn User keine Files hat", async () => {
|
|
286
307
|
const result = await fileRefExportHook({
|
|
287
|
-
db: stack.db,
|
|
308
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
288
309
|
registry: stack.registry,
|
|
289
310
|
tenantId: TENANT_A,
|
|
290
311
|
userId: "ghost-user-no-files",
|
|
@@ -299,7 +320,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
299
320
|
await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
|
|
300
321
|
|
|
301
322
|
await fileRefDeleteHook(
|
|
302
|
-
{
|
|
323
|
+
{
|
|
324
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
325
|
+
registry: stack.registry,
|
|
326
|
+
tenantId: TENANT_A,
|
|
327
|
+
userId: "user-delete-files",
|
|
328
|
+
},
|
|
303
329
|
"delete",
|
|
304
330
|
);
|
|
305
331
|
|
|
@@ -311,7 +337,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
311
337
|
await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
|
|
312
338
|
|
|
313
339
|
await fileRefDeleteHook(
|
|
314
|
-
{
|
|
340
|
+
{
|
|
341
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
342
|
+
registry: stack.registry,
|
|
343
|
+
tenantId: TENANT_A,
|
|
344
|
+
userId: "user-anon-files",
|
|
345
|
+
},
|
|
315
346
|
"anonymize",
|
|
316
347
|
);
|
|
317
348
|
|
|
@@ -329,7 +360,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
329
360
|
|
|
330
361
|
// Tenant A loescht alle Files von "shared-user"
|
|
331
362
|
await fileRefDeleteHook(
|
|
332
|
-
{
|
|
363
|
+
{
|
|
364
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
365
|
+
registry: stack.registry,
|
|
366
|
+
tenantId: TENANT_A,
|
|
367
|
+
userId: "shared-user",
|
|
368
|
+
},
|
|
333
369
|
"delete",
|
|
334
370
|
);
|
|
335
371
|
|
|
@@ -345,7 +381,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
345
381
|
await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
|
|
346
382
|
|
|
347
383
|
await fileRefDeleteHook(
|
|
348
|
-
{
|
|
384
|
+
{
|
|
385
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
386
|
+
registry: stack.registry,
|
|
387
|
+
tenantId: TENANT_A,
|
|
388
|
+
userId: "user-idem-files",
|
|
389
|
+
},
|
|
349
390
|
"delete",
|
|
350
391
|
);
|
|
351
392
|
const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
@@ -354,7 +395,12 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
354
395
|
// Zweiter Call: kein Crash + State weiter 0 Files
|
|
355
396
|
await expect(
|
|
356
397
|
fileRefDeleteHook(
|
|
357
|
-
{
|
|
398
|
+
{
|
|
399
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
400
|
+
registry: stack.registry,
|
|
401
|
+
tenantId: TENANT_A,
|
|
402
|
+
userId: "user-idem-files",
|
|
403
|
+
},
|
|
358
404
|
"delete",
|
|
359
405
|
),
|
|
360
406
|
).resolves.toBeUndefined();
|
|
@@ -398,7 +444,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
|
|
|
398
444
|
|
|
399
445
|
await fileRefDeleteHook(
|
|
400
446
|
{
|
|
401
|
-
db: stack.db,
|
|
447
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
402
448
|
registry: stack.registry,
|
|
403
449
|
tenantId: TENANT_A,
|
|
404
450
|
userId,
|
|
@@ -428,7 +474,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
|
|
|
428
474
|
|
|
429
475
|
await fileRefDeleteHook(
|
|
430
476
|
{
|
|
431
|
-
db: stack.db,
|
|
477
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
432
478
|
registry: stack.registry,
|
|
433
479
|
tenantId: TENANT_A,
|
|
434
480
|
userId,
|
|
@@ -459,7 +505,7 @@ describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue
|
|
|
459
505
|
await expect(
|
|
460
506
|
fileRefDeleteHook(
|
|
461
507
|
{
|
|
462
|
-
db: stack.db,
|
|
508
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
463
509
|
registry: stack.registry,
|
|
464
510
|
tenantId: TENANT_A,
|
|
465
511
|
userId,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { apiTokenTable } from "../../personal-access-tokens";
|
|
4
3
|
import { featureMounted } from "./feature-mounted";
|
|
@@ -11,7 +10,7 @@ import { featureMounted } from "./feature-mounted";
|
|
|
11
10
|
|
|
12
11
|
export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
|
|
13
12
|
if (!featureMounted(ctx, "personal-access-tokens")) return null;
|
|
14
|
-
const rows = await
|
|
13
|
+
const rows = await ctx.db.selectMany(apiTokenTable, {
|
|
15
14
|
userId: ctx.userId,
|
|
16
15
|
tenantId: ctx.tenantId,
|
|
17
16
|
});
|
|
@@ -33,5 +32,5 @@ export const apiTokenExportHook: UserDataExportHook = async (ctx) => {
|
|
|
33
32
|
export const apiTokenDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
34
33
|
// skip: personal-access-tokens not mounted — its table doesn't exist, nothing to erase.
|
|
35
34
|
if (!featureMounted(ctx, "personal-access-tokens")) return;
|
|
36
|
-
await
|
|
35
|
+
await ctx.db.deleteMany(apiTokenTable, { userId: ctx.userId, tenantId: ctx.tenantId });
|
|
37
36
|
};
|