@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.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 +13 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
tenantExportPrefix,
|
|
62
62
|
type ZipEntry,
|
|
63
63
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
64
|
+
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
64
65
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
65
66
|
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
66
67
|
import { decryptStoredPii } from "../shared";
|
|
@@ -73,6 +74,12 @@ import { generateDownloadToken } from "./token-helpers";
|
|
|
73
74
|
|
|
74
75
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
75
76
|
|
|
77
|
+
// Gauge short name (feature.ts registers it via r.metric + resolves ctx.meter
|
|
78
|
+
// into a MetricsHandle — jobs get no bound ctx.metrics, see feature.ts).
|
|
79
|
+
// No "_seconds"/"_total" suffix: validateMetricName rejects both on a gauge.
|
|
80
|
+
// Unit lives in the r.metric() `unit: "seconds"` declaration instead.
|
|
81
|
+
export const EXPORT_CLEANUP_BACKLOG_AGE_METRIC = "export_cleanup_backlog_age";
|
|
82
|
+
|
|
76
83
|
const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
|
|
77
84
|
entityName: "export-job",
|
|
78
85
|
});
|
|
@@ -165,6 +172,12 @@ export interface RunExportJobsArgs {
|
|
|
165
172
|
// systemUser.id); threaded into runUserExport's per-hook TenantDb.
|
|
166
173
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
167
174
|
readonly actor?: string;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reports EXPORT_CLEANUP_BACKLOG_AGE_METRIC after the storage-cleanup
|
|
178
|
+
* pass. Optional so callers without an observability provider still work.
|
|
179
|
+
*/
|
|
180
|
+
readonly metrics?: MetricsHandle;
|
|
168
181
|
}
|
|
169
182
|
|
|
170
183
|
export interface ExportJobError {
|
|
@@ -302,6 +315,7 @@ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJ
|
|
|
302
315
|
db,
|
|
303
316
|
buildStorageProvider,
|
|
304
317
|
now,
|
|
318
|
+
metrics: args.metrics,
|
|
305
319
|
});
|
|
306
320
|
|
|
307
321
|
return {
|
|
@@ -621,8 +635,9 @@ async function storageCleanupPass(args: {
|
|
|
621
635
|
db: DbConnection;
|
|
622
636
|
buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
|
|
623
637
|
now: Instant;
|
|
638
|
+
metrics?: MetricsHandle;
|
|
624
639
|
}): Promise<readonly string[]> {
|
|
625
|
-
const { db, buildStorageProvider, now } = args;
|
|
640
|
+
const { db, buildStorageProvider, now, metrics } = args;
|
|
626
641
|
|
|
627
642
|
// Zwei Cleanup-Pfade:
|
|
628
643
|
//
|
|
@@ -659,6 +674,13 @@ async function storageCleanupPass(args: {
|
|
|
659
674
|
);
|
|
660
675
|
|
|
661
676
|
const cleaned: string[] = [];
|
|
677
|
+
// Done-status candidates past expiresAt+grace, keyed by that due-instant —
|
|
678
|
+
// used below to report how far cleanup has fallen behind if a candidate
|
|
679
|
+
// is still standing (delete threw, or the version-checked null-out lost
|
|
680
|
+
// a race) once the pass is done. Failed-status jobs have no expiresAt
|
|
681
|
+
// (immediate cleanup, no TTL) so they're out of scope for this signal —
|
|
682
|
+
// see EXPORT_CLEANUP_BACKLOG_AGE_METRIC's r.metric() description.
|
|
683
|
+
const overdueDoneCleanupAfterMs = new Map<string, number>();
|
|
662
684
|
for (const c of candidates) {
|
|
663
685
|
if (!c.downloadStorageKey) continue;
|
|
664
686
|
|
|
@@ -674,6 +696,7 @@ async function storageCleanupPass(args: {
|
|
|
674
696
|
c.expiresAt.epochMilliseconds +
|
|
675
697
|
profile.profile.userRights.exportStorageCleanupGraceHours * 60 * 60 * 1000;
|
|
676
698
|
if (now.epochMilliseconds < cleanupAfter) continue;
|
|
699
|
+
overdueDoneCleanupAfterMs.set(c.id, cleanupAfter);
|
|
677
700
|
}
|
|
678
701
|
// Failed-Job-Branch: kein TTL-Check, sofort cleanup.
|
|
679
702
|
|
|
@@ -696,9 +719,54 @@ async function storageCleanupPass(args: {
|
|
|
696
719
|
);
|
|
697
720
|
if (result.isSuccess) cleaned.push(c.id);
|
|
698
721
|
}
|
|
722
|
+
|
|
723
|
+
reportOldestOverdueAge(metrics, overdueDoneCleanupAfterMs, cleaned, now);
|
|
724
|
+
|
|
699
725
|
return cleaned;
|
|
700
726
|
}
|
|
701
727
|
|
|
728
|
+
// Seconds since the longest-overdue Done-status bundle should have been
|
|
729
|
+
// deleted (expiresAt+grace passed) but still isn't — 0 when the pass
|
|
730
|
+
// cleaned everything that was due, or nothing was due.
|
|
731
|
+
function oldestOverdueAgeSeconds(
|
|
732
|
+
overdueDoneCleanupAfterMs: ReadonlyMap<string, number>,
|
|
733
|
+
cleanedIds: readonly string[],
|
|
734
|
+
now: Instant,
|
|
735
|
+
): number {
|
|
736
|
+
const cleaned = new Set(cleanedIds);
|
|
737
|
+
let oldestAgeSeconds = 0;
|
|
738
|
+
for (const [id, cleanupAfterMs] of overdueDoneCleanupAfterMs) {
|
|
739
|
+
if (cleaned.has(id)) continue;
|
|
740
|
+
const ageSeconds = (now.epochMilliseconds - cleanupAfterMs) / 1000;
|
|
741
|
+
if (ageSeconds > oldestAgeSeconds) oldestAgeSeconds = ageSeconds;
|
|
742
|
+
}
|
|
743
|
+
return oldestAgeSeconds;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Best-effort like the email callbacks above: an observability hiccup
|
|
747
|
+
// (metric not yet registered on this meter, provider misconfig) must not
|
|
748
|
+
// fail a pass that otherwise cleaned up fine.
|
|
749
|
+
function reportOldestOverdueAge(
|
|
750
|
+
metrics: MetricsHandle | undefined,
|
|
751
|
+
overdueDoneCleanupAfterMs: ReadonlyMap<string, number>,
|
|
752
|
+
cleanedIds: readonly string[],
|
|
753
|
+
now: Instant,
|
|
754
|
+
): void {
|
|
755
|
+
// skip: no meter configured means there is nothing to report — the cleanup pass itself already ran to completion.
|
|
756
|
+
if (!metrics) return;
|
|
757
|
+
try {
|
|
758
|
+
metrics.set(
|
|
759
|
+
EXPORT_CLEANUP_BACKLOG_AGE_METRIC,
|
|
760
|
+
oldestOverdueAgeSeconds(overdueDoneCleanupAfterMs, cleanedIds, now),
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for observability-emit-failure
|
|
764
|
+
console.warn(
|
|
765
|
+
`[user-data-rights:run-export-jobs] metrics.set(${EXPORT_CLEANUP_BACKLOG_AGE_METRIC}) failed err=${err instanceof Error ? err.message : String(err)}`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
702
770
|
// Wrapper: crud.update braucht TenantDb. ExportJob ist tenant-agnostisch
|
|
703
771
|
// (1 Job pro userId), aber der event-store-Stream-Lookup erwartet einen
|
|
704
772
|
// Tenant-Context. system-mode TenantDb bypassed den auto-tenant-filter
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -15,7 +15,13 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
|
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
17
|
import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createEntity,
|
|
20
|
+
createFileField,
|
|
21
|
+
createImageField,
|
|
22
|
+
defineFeature,
|
|
23
|
+
SYSTEM_TENANT_ID,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
25
|
import {
|
|
20
26
|
createInMemoryFileProvider,
|
|
21
27
|
deriveKey,
|
|
@@ -29,7 +35,9 @@ import {
|
|
|
29
35
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
36
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
37
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
38
|
+
import { createConfigFeature } from "../../config";
|
|
32
39
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
40
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
33
41
|
import { createFilesFeature } from "../../files";
|
|
34
42
|
import { createSessionsFeature } from "../../sessions";
|
|
35
43
|
import {
|
|
@@ -46,15 +54,50 @@ import { fileRefDeleteHook, fileRefExportHook, userDeleteHook, userExportHook }
|
|
|
46
54
|
|
|
47
55
|
let stack: TestStack;
|
|
48
56
|
|
|
57
|
+
// #3005 fixture: a business entity with file fields covering the three
|
|
58
|
+
// field-annotation shapes fileRefDeleteHook must branch on — explicitly
|
|
59
|
+
// non-personal (dealer's own business data), personal (self), and no
|
|
60
|
+
// annotation at all (the conservative "no annotation" case).
|
|
61
|
+
const vehicleEntity = createEntity({
|
|
62
|
+
table: "test_vehicles",
|
|
63
|
+
fields: {
|
|
64
|
+
dealerPhoto: createImageField({
|
|
65
|
+
personal: false,
|
|
66
|
+
reason: "dealer_business_data_test_fixture",
|
|
67
|
+
}),
|
|
68
|
+
driverSelfie: createImageField({ personal: "self" }),
|
|
69
|
+
unannotatedDoc: createFileField(),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const vehicleFeature = defineFeature("testVehicleFileFields", (r) => {
|
|
74
|
+
r.entity("vehicle", vehicleEntity);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The vehicle fixture's image/file fields make server.ts's boot guard require
|
|
78
|
+
// a mounted file-storage provider (registryDeclaresFileFields). The hook
|
|
79
|
+
// tests below never resolve a provider through the registry — each passes
|
|
80
|
+
// its own `buildStorageProvider` directly — so this one only exists to
|
|
81
|
+
// satisfy that boot check.
|
|
82
|
+
const bootGuardFileProvider = createInMemoryFileProvider();
|
|
83
|
+
const testFileProviderFeature = defineFeature("testFileProviderBootGuard", (r) => {
|
|
84
|
+
r.requires("file-foundation");
|
|
85
|
+
r.useExtension("fileProvider", "test", { build: async () => bootGuardFileProvider });
|
|
86
|
+
});
|
|
87
|
+
|
|
49
88
|
const features = [
|
|
50
89
|
createUserFeature(),
|
|
90
|
+
createConfigFeature(),
|
|
51
91
|
createFilesFeature(),
|
|
92
|
+
fileFoundationFeature,
|
|
93
|
+
testFileProviderFeature,
|
|
52
94
|
createDataRetentionFeature(),
|
|
53
95
|
createComplianceProfilesFeature(),
|
|
54
96
|
authFoundationFeature,
|
|
55
97
|
createSessionsFeature(),
|
|
56
98
|
createUserDataRightsFeature(),
|
|
57
99
|
createUserDataRightsDefaultsFeature(),
|
|
100
|
+
vehicleFeature,
|
|
58
101
|
];
|
|
59
102
|
|
|
60
103
|
beforeAll(async () => {
|
|
@@ -119,6 +162,35 @@ async function seedFileRef(
|
|
|
119
162
|
);
|
|
120
163
|
}
|
|
121
164
|
|
|
165
|
+
// #3005: seeds a fileRef attached to an entity/field, for testing the
|
|
166
|
+
// per-row PII-vs-business-data decision in fileRefDeleteHook.
|
|
167
|
+
async function seedFileRefWithField(
|
|
168
|
+
id: string,
|
|
169
|
+
tenantId: string,
|
|
170
|
+
insertedById: string,
|
|
171
|
+
entityType: string | null,
|
|
172
|
+
fieldName: string | null,
|
|
173
|
+
fileName: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
await asRawClient(stack.db).unsafe(
|
|
176
|
+
`
|
|
177
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, entity_type, entity_id, field_name, inserted_by_id)
|
|
178
|
+
VALUES ($1, $2, $3, $4, 'image/jpeg', 1024, $5, $6, $7, $8)
|
|
179
|
+
ON CONFLICT (id) DO NOTHING
|
|
180
|
+
`,
|
|
181
|
+
[
|
|
182
|
+
id,
|
|
183
|
+
tenantId,
|
|
184
|
+
`storage/${id}`,
|
|
185
|
+
fileName,
|
|
186
|
+
entityType,
|
|
187
|
+
entityType !== null ? "1" : null,
|
|
188
|
+
fieldName,
|
|
189
|
+
insertedById,
|
|
190
|
+
],
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
122
194
|
async function fetchUser(id: string) {
|
|
123
195
|
const result = await asRawClient(stack.db).unsafe(
|
|
124
196
|
`
|
|
@@ -409,6 +481,131 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
409
481
|
});
|
|
410
482
|
});
|
|
411
483
|
|
|
484
|
+
describe("S2.H2 :: fileRefDeleteHook — per-row PII decision (issue #3005)", () => {
|
|
485
|
+
test('strategy="delete" — field explicitly non-personal (dealer business data): binary + row survive, insertedById=null', async () => {
|
|
486
|
+
const userId = "user-dealer-photo";
|
|
487
|
+
await seedFileRefWithField(
|
|
488
|
+
uuid(601),
|
|
489
|
+
TENANT_A,
|
|
490
|
+
userId,
|
|
491
|
+
"vehicle",
|
|
492
|
+
"dealerPhoto",
|
|
493
|
+
"vehicle-front.jpg",
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
await fileRefDeleteHook(
|
|
497
|
+
{
|
|
498
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
499
|
+
registry: stack.registry,
|
|
500
|
+
tenantId: TENANT_A,
|
|
501
|
+
userId,
|
|
502
|
+
},
|
|
503
|
+
"delete",
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const ownedAfter = await fetchFileRefs(TENANT_A, userId);
|
|
507
|
+
expect(ownedAfter).toHaveLength(0);
|
|
508
|
+
const anonymized = await fetchFileRefs(TENANT_A, null);
|
|
509
|
+
const row = anonymized.find((f: { id: string }) => f.id === uuid(601));
|
|
510
|
+
expect(row).toBeDefined();
|
|
511
|
+
expect(row.inserted_by_id).toBeNull();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('strategy="delete" — field marked personal (self): binary + row are gone', async () => {
|
|
515
|
+
const userId = "user-driver-selfie";
|
|
516
|
+
await seedFileRefWithField(
|
|
517
|
+
uuid(602),
|
|
518
|
+
TENANT_A,
|
|
519
|
+
userId,
|
|
520
|
+
"vehicle",
|
|
521
|
+
"driverSelfie",
|
|
522
|
+
"selfie.jpg",
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
await fileRefDeleteHook(
|
|
526
|
+
{
|
|
527
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
528
|
+
registry: stack.registry,
|
|
529
|
+
tenantId: TENANT_A,
|
|
530
|
+
userId,
|
|
531
|
+
},
|
|
532
|
+
"delete",
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const remaining = await fetchFileRefs(TENANT_A);
|
|
536
|
+
expect(remaining.find((f: { id: string }) => f.id === uuid(602))).toBeUndefined();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('strategy="delete" — unattached upload (entityType/fieldName both null): hard-deleted', async () => {
|
|
540
|
+
const userId = "user-unattached-hard";
|
|
541
|
+
await seedFileRefWithField(uuid(603), TENANT_A, userId, null, null, "loose-file.pdf");
|
|
542
|
+
|
|
543
|
+
await fileRefDeleteHook(
|
|
544
|
+
{
|
|
545
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
546
|
+
registry: stack.registry,
|
|
547
|
+
tenantId: TENANT_A,
|
|
548
|
+
userId,
|
|
549
|
+
},
|
|
550
|
+
"delete",
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const remaining = await fetchFileRefs(TENANT_A);
|
|
554
|
+
expect(remaining.find((f: { id: string }) => f.id === uuid(603))).toBeUndefined();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('strategy="delete" — field exists but carries no annotation: anonymized, not hard-deleted', async () => {
|
|
558
|
+
const userId = "user-unannotated-field";
|
|
559
|
+
await seedFileRefWithField(uuid(604), TENANT_A, userId, "vehicle", "unannotatedDoc", "doc.pdf");
|
|
560
|
+
|
|
561
|
+
await fileRefDeleteHook(
|
|
562
|
+
{
|
|
563
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
564
|
+
registry: stack.registry,
|
|
565
|
+
tenantId: TENANT_A,
|
|
566
|
+
userId,
|
|
567
|
+
},
|
|
568
|
+
"delete",
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
const ownedAfter = await fetchFileRefs(TENANT_A, userId);
|
|
572
|
+
expect(ownedAfter).toHaveLength(0);
|
|
573
|
+
const anonymized = await fetchFileRefs(TENANT_A, null);
|
|
574
|
+
const row = anonymized.find((f: { id: string }) => f.id === uuid(604));
|
|
575
|
+
expect(row).toBeDefined();
|
|
576
|
+
expect(row.inserted_by_id).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('strategy="delete" — entityType not resolvable in the registry: anonymized, never a silent hard-delete', async () => {
|
|
580
|
+
const userId = "user-unresolvable-entity";
|
|
581
|
+
await seedFileRefWithField(
|
|
582
|
+
uuid(605),
|
|
583
|
+
TENANT_A,
|
|
584
|
+
userId,
|
|
585
|
+
"no-such-entity",
|
|
586
|
+
"somefield",
|
|
587
|
+
"orphaned.pdf",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
await fileRefDeleteHook(
|
|
591
|
+
{
|
|
592
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
593
|
+
registry: stack.registry,
|
|
594
|
+
tenantId: TENANT_A,
|
|
595
|
+
userId,
|
|
596
|
+
},
|
|
597
|
+
"delete",
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
const ownedAfter = await fetchFileRefs(TENANT_A, userId);
|
|
601
|
+
expect(ownedAfter).toHaveLength(0);
|
|
602
|
+
const anonymized = await fetchFileRefs(TENANT_A, null);
|
|
603
|
+
const row = anonymized.find((f: { id: string }) => f.id === uuid(605));
|
|
604
|
+
expect(row).toBeDefined();
|
|
605
|
+
expect(row.inserted_by_id).toBeNull();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
412
609
|
describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue #2461)", () => {
|
|
413
610
|
async function seedFileRefWithKey(
|
|
414
611
|
id: string,
|
|
@@ -2,6 +2,8 @@ import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-fra
|
|
|
2
2
|
import { derivativeListPrefix, isDerivativeKeyOf } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
3
3
|
import {
|
|
4
4
|
createSystemUser,
|
|
5
|
+
type FieldDefinition,
|
|
6
|
+
type Registry,
|
|
5
7
|
type SessionUser,
|
|
6
8
|
type UserDataDeleteHook,
|
|
7
9
|
type UserDataExportHook,
|
|
@@ -28,10 +30,18 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
|
|
|
28
30
|
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
29
31
|
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
30
32
|
//
|
|
31
|
-
// Delete-
|
|
32
|
-
// "delete": storageProvider.delete()
|
|
33
|
-
//
|
|
34
|
-
//
|
|
33
|
+
// Delete-hook removes the fileRef row + binary:
|
|
34
|
+
// "delete": storageProvider.delete() per file + row hard-delete — but
|
|
35
|
+
// ONLY for rows whose field is marked as PII of the person
|
|
36
|
+
// (isPersonalFileRow, #3005). All other rows (business/
|
|
37
|
+
// tenant data, no resolvable field, no annotation) go
|
|
38
|
+
// through severPersonLink instead, even when the entity
|
|
39
|
+
// strategy is "delete" — the uploader axis (insertedById) is
|
|
40
|
+
// not the subject axis.
|
|
41
|
+
// "anonymize": insertedById=null, row + binary survive (FK refs can still
|
|
42
|
+
// point at it; person-link removed) — applies to ALL rows
|
|
43
|
+
// when the entity strategy itself is already anonymize (e.g.
|
|
44
|
+
// blockDelete retention), regardless of the field.
|
|
35
45
|
//
|
|
36
46
|
// **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
|
|
37
47
|
// `ctx.buildStorageProvider(ctx.tenantId)` — der Forget-Orchestrator
|
|
@@ -124,6 +134,46 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
|
|
137
|
+
// Per-row Art.17 decision (kumiko-framework#3005): whether THIS file's field
|
|
138
|
+
// carries personal data of the forgotten person, or is business/tenant data
|
|
139
|
+
// that merely loses its uploader-attribution. insertedById names who
|
|
140
|
+
// UPLOADED the file, not whose data it is — a dealer's vehicle photo
|
|
141
|
+
// uploaded by an employee is the dealer's business data, not the employee's
|
|
142
|
+
// PII. The field's own personal-annotation decides:
|
|
143
|
+
// - pii / userOwned / recordOwned → the field's content IS personal data
|
|
144
|
+
// of a person (self, owner-referenced, or the record's own subject) →
|
|
145
|
+
// hard-delete path.
|
|
146
|
+
// - anything else (explicit `personal: false`, `tenantOwned`,
|
|
147
|
+
// `subjectRef`, or no annotation at all) → not personal-to-a-person
|
|
148
|
+
// content → sever the uploader link only, keep row + binary.
|
|
149
|
+
function isPersonalPiiField(field: FieldDefinition | undefined): boolean {
|
|
150
|
+
if (!field) return false;
|
|
151
|
+
return (
|
|
152
|
+
("pii" in field && field.pii === true) ||
|
|
153
|
+
("userOwned" in field && field.userOwned !== undefined) ||
|
|
154
|
+
("recordOwned" in field && field.recordOwned === true)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Three cases the field-lookup itself can't resolve (issue #3005's rule for
|
|
159
|
+
// the non-resolvable cases):
|
|
160
|
+
// 1. Unattached upload (entityType/fieldName both null) → hard-delete: a
|
|
161
|
+
// file with no entity binding belongs to nobody but its uploader.
|
|
162
|
+
// 2. Field exists but carries no PII annotation → anonymize (the
|
|
163
|
+
// conservative path); the boot-validator warns on a PII-typical field
|
|
164
|
+
// name so this stays visible and quiet-fixable via `personal`.
|
|
165
|
+
// 3. Entity or field not resolvable (upload hardening in file-routes.ts
|
|
166
|
+
// should prevent this from ever being written; theoretical remainder
|
|
167
|
+
// only) → anonymize, never a silent hard-delete.
|
|
168
|
+
function isPersonalFileRow(registry: Registry, row: Record<string, unknown>): boolean {
|
|
169
|
+
const entityType = row["entityType"]; // @cast-boundary db-row
|
|
170
|
+
const fieldName = row["fieldName"]; // @cast-boundary db-row
|
|
171
|
+
if (entityType === null && fieldName === null) return true;
|
|
172
|
+
if (typeof entityType !== "string" || typeof fieldName !== "string") return false;
|
|
173
|
+
const fieldDef = registry.getEntity(entityType)?.fields[fieldName];
|
|
174
|
+
return isPersonalPiiField(fieldDef);
|
|
175
|
+
}
|
|
176
|
+
|
|
127
177
|
// Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
|
|
128
178
|
// never tracked anywhere but the storage layer: they're rendered under a
|
|
129
179
|
// deterministic key (deriveKey + variantSuffix) computed from the original's
|
|
@@ -221,14 +271,34 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
221
271
|
if (strategy !== "delete") {
|
|
222
272
|
// anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
|
|
223
273
|
// chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
|
|
274
|
+
// Applies to ALL rows — the entity strategy comes from a retention
|
|
275
|
+
// policy (e.g. blockDelete) and overrides the per-field decision below,
|
|
276
|
+
// which only applies for strategy="delete".
|
|
224
277
|
await severPersonLink(ctx.db, systemUser, rows);
|
|
225
278
|
// skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
|
|
226
279
|
return;
|
|
227
280
|
}
|
|
228
281
|
|
|
282
|
+
// strategy="delete" decides PER ROW based on the field, not uniformly
|
|
283
|
+
// (#3005): only rows whose field is marked as PII of the person (or
|
|
284
|
+
// unattached, see isPersonalFileRow) take the hard path. Everything else
|
|
285
|
+
// only loses the uploader link.
|
|
286
|
+
const personalRows: Record<string, unknown>[] = [];
|
|
287
|
+
const businessRows: Record<string, unknown>[] = [];
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
(isPersonalFileRow(ctx.registry, row) ? personalRows : businessRows).push(row);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (businessRows.length > 0) {
|
|
293
|
+
await severPersonLink(ctx.db, systemUser, businessRows);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// skip: nothing left to hard-delete — businessRows above already had their person link severed via severPersonLink.
|
|
297
|
+
if (personalRows.length === 0) return;
|
|
298
|
+
|
|
229
299
|
const storageProvider = await resolveProvider(ctx);
|
|
230
300
|
if (storageProvider) {
|
|
231
|
-
const failedKeys = await deleteBinaries(
|
|
301
|
+
const failedKeys = await deleteBinaries(personalRows, storageProvider);
|
|
232
302
|
if (failedKeys.length > 0) {
|
|
233
303
|
throw new Error(
|
|
234
304
|
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
@@ -250,7 +320,7 @@ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
|
250
320
|
// auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
|
|
251
321
|
// The old hard deleteMany was resurrected on rebuild; this closes that Art.17
|
|
252
322
|
// hole without a direct write.
|
|
253
|
-
for (const row of
|
|
323
|
+
for (const row of personalRows) {
|
|
254
324
|
const id = row["id"]; // @cast-boundary db-row
|
|
255
325
|
if (typeof id !== "string") continue;
|
|
256
326
|
assertErased(await crud.forget({ id }, systemUser, ctx.db), "fileRef", id);
|