@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.288.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 +12 -9
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/subscription-tier-sync.ts +9 -4
- 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 +12 -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/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
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "export-cleanup cron now emits an export_cleanup_backlog_age gauge to surface stalled runs",
|
|
6
|
+
"detail": "storageCleanupPass deletes expired export bundles via provider.delete, but the storage provider only exposes list() (no timestamps), so a silent failure left unencrypted PII bundles sitting in the bucket with no signal. runExportJobs now accepts an optional MetricsHandle and, after the cleanup loop, reports the age in seconds of the oldest done-status job whose expiresAt+grace has passed while downloadStorageKey is still set (0 if none) via the new EXPORT_CLEANUP_BACKLOG_AGE_METRIC (\"export_cleanup_backlog_age\", a gauge — no _total/_seconds suffix per validateMetricName, unit: \"seconds\" carried in the r.metric() definition instead). feature.ts's job handler builds the handle itself with createMetricsHandle(ctx.meter, \"user-data-rights\") since JobContext has no bound ctx.metrics (that only exists on HandlerContext, built per write/query dispatch) — falls back to createNoopMetricsHandle() when ctx.meter is unset. Metric emission is wrapped in try/catch so an observability hiccup never fails an otherwise-successful cleanup pass. Scope is deliberately done-status only; failed-status cleanup candidates have no expiresAt/TTL."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.287.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "GDPR forget only hard-deletes files that are PII of the forgotten person",
|
|
12
|
+
"detail": "fileRefDeleteHook picked rows via insertedById (who uploaded the file) and treated every matched row the same on strategy=\"delete\", so forgetting an uploader hard-deleted third-party business files they had merely uploaded (e.g. a dealer's vehicle photos uploaded by an employee). FileFieldDef/ImageFieldDef/FilesFieldDef/ImagesFieldDef now support \"& ResolvedPiiFlags\" like every other field type (createFileField/createImageField/createFilesField/createImagesField thread personal/reason through expandPersonalAnnotations), and fileRefDeleteHook's strategy=\"delete\" branch splits rows per-row via the new isPersonalFileRow/isPersonalPiiField predicates: pii/userOwned/recordOwned fields take the existing hard-delete path (storageProvider.delete() + derivatives + row), every other row (explicit personal:false, tenantOwned, subjectRef, no annotation, or an unresolvable entity/field) goes through severPersonLink instead, same as the existing strategy=\"anonymize\" path. Unattached uploads (no entityType/fieldName) still hard-delete. Derivatives have no own file_refs row, so they keep following their original's decision (the #2461 \"GDPR derivatives survive forget\" test stays green). POST /files now resolves entityType/fieldName against the registry and rejects the upload with 400 when they're set but don't resolve to a real field, closing the gap that let a client attach a file to a field the forget-hook could never look up; both unset (unattached upload) still passes. No new column or migration — the decision uses the entityType/fieldName file_refs already stores."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.285.2",
|
|
4
16
|
"type": "fix",
|
|
@@ -1,62 +1,56 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Email-verified account deletion, expressed as a row-bound grant: the
|
|
2
|
+
// subject is the user, the anchor is the pending request id stored on the
|
|
3
|
+
// user row. Minting and verification both run through
|
|
4
|
+
// shared/row-bound-grant, so a token from a cancelled cycle (id nulled) or a
|
|
5
|
+
// superseded one (row holds a newer id) fails verification — see #354/1.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// The same id is stored on the user row when the request is minted and nulled
|
|
10
|
-
// on cancel. confirm recomputes the HMAC with the row's CURRENT id, so a token
|
|
11
|
-
// from a cancelled cycle (row id nulled) or a superseded one (row holds a newer
|
|
12
|
-
// id) fails verification — the bounded-TTL replay window is closed without
|
|
13
|
-
// touching the shared signToken/verifyToken primitive.
|
|
7
|
+
// The purpose string and the anchoring are unchanged from the hand-rolled
|
|
8
|
+
// version this replaced, so tokens stay byte-compatible.
|
|
14
9
|
|
|
15
10
|
import type { Temporal } from "temporal-polyfill";
|
|
16
|
-
import {
|
|
11
|
+
import { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant } from "../shared";
|
|
17
12
|
|
|
18
13
|
const DELETION_REQUEST_PURPOSE = "deletion-request";
|
|
19
14
|
|
|
20
|
-
export type VerifyResult =
|
|
21
|
-
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
22
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
23
|
-
|
|
24
|
-
function deletionPurpose(requestId: string): string {
|
|
25
|
-
return `${DELETION_REQUEST_PURPOSE}:${requestId}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
15
|
export function signDeletionToken(
|
|
29
16
|
userId: string,
|
|
30
17
|
requestId: string,
|
|
31
18
|
ttlMinutes: number,
|
|
32
19
|
secret: string,
|
|
33
20
|
now?: Temporal.Instant,
|
|
34
|
-
): { token: string; expiresAt: Temporal.Instant } {
|
|
35
|
-
return
|
|
21
|
+
): { readonly token: string; readonly expiresAt: Temporal.Instant } {
|
|
22
|
+
return signRowBoundGrant({
|
|
23
|
+
subject: userId,
|
|
24
|
+
purpose: DELETION_REQUEST_PURPOSE,
|
|
25
|
+
anchor: requestId,
|
|
26
|
+
ttlMinutes,
|
|
27
|
+
secret,
|
|
28
|
+
now,
|
|
29
|
+
});
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
export function
|
|
39
|
-
token: string
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
now?: Temporal.Instant
|
|
43
|
-
):
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
32
|
+
export function redeemDeletionToken(args: {
|
|
33
|
+
readonly token: string;
|
|
34
|
+
readonly secret: string | undefined;
|
|
35
|
+
readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
|
|
36
|
+
readonly now?: Temporal.Instant;
|
|
37
|
+
}): Promise<RowBoundGrantResult> {
|
|
38
|
+
return redeemRowBoundGrant({
|
|
39
|
+
token: args.token,
|
|
40
|
+
purpose: DELETION_REQUEST_PURPOSE,
|
|
41
|
+
secret: args.secret,
|
|
42
|
+
loadAnchor: args.loadPendingRequestId,
|
|
43
|
+
commitAnchor: {
|
|
44
|
+
unsafeSkip: {
|
|
45
|
+
reason:
|
|
46
|
+
"pendingDeletionRequestId may only be changed through updateUserLifecycle — a " +
|
|
47
|
+
"conditional UPDATE would bypass the user.updated event and lose the field on a " +
|
|
48
|
+
"projection rebuild (see update-user-lifecycle.ts). The concurrent-redeem window " +
|
|
49
|
+
"this leaves open predates row-bound grants: startDeletionGracePeriod already " +
|
|
50
|
+
"read-then-writes the Active check. Closing it needs an atomic lifecycle " +
|
|
51
|
+
"transition, which is its own change.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
now: args.now,
|
|
55
|
+
});
|
|
62
56
|
}
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
i18nKey,
|
|
8
8
|
SYSTEM_USER_ID,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
createMetricsHandle,
|
|
12
|
+
createNoopMetricsHandle,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
10
14
|
import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
|
|
11
15
|
import {
|
|
12
16
|
EXPORT_SECTION_EXTENSION_NAME,
|
|
@@ -49,6 +53,7 @@ import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver";
|
|
|
49
53
|
import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
|
|
50
54
|
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
|
|
51
55
|
import {
|
|
56
|
+
EXPORT_CLEANUP_BACKLOG_AGE_METRIC,
|
|
52
57
|
runExportJobs,
|
|
53
58
|
type SendExportFailedEmailFn,
|
|
54
59
|
type SendExportReadyEmailFn,
|
|
@@ -528,6 +533,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
528
533
|
},
|
|
529
534
|
});
|
|
530
535
|
|
|
536
|
+
// Surfaces a stalled export-cleanup cron: if the daily
|
|
537
|
+
// pass stops running, done-status export bundles keep sitting in
|
|
538
|
+
// storage past their TTL+grace with downloadStorageKey still set
|
|
539
|
+
// (unencrypted PII at rest). Emitted from the same job that cleans up
|
|
540
|
+
// (see handler below) — a dead cron makes the metric go stale too,
|
|
541
|
+
// which an `absent()` alert catches without a second liveness signal.
|
|
542
|
+
// Scope: Done-status bundles only — Failed-status cleanup candidates
|
|
543
|
+
// have no expiresAt/TTL (immediate cleanup, see storageCleanupPass).
|
|
544
|
+
r.metric(EXPORT_CLEANUP_BACKLOG_AGE_METRIC, {
|
|
545
|
+
type: "gauge",
|
|
546
|
+
unit: "seconds",
|
|
547
|
+
description:
|
|
548
|
+
"Age in seconds of the oldest done-status export bundle whose expiresAt+grace has passed but downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
|
|
549
|
+
});
|
|
550
|
+
|
|
531
551
|
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
532
552
|
const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
|
|
533
553
|
r.job({
|
|
@@ -548,6 +568,12 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
548
568
|
RUN_EXPORT_JOBS_REASON,
|
|
549
569
|
) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
550
570
|
const exportRegistry = ctx.registry;
|
|
571
|
+
// JobContext carries ctx.meter (raw), not ctx.metrics — that bound
|
|
572
|
+
// handle only exists on HandlerContext (built per write/query call
|
|
573
|
+
// in dispatch-shared.ts). Build the same handle explicitly here.
|
|
574
|
+
const exportMetrics = ctx.meter
|
|
575
|
+
? createMetricsHandle(ctx.meter, "user-data-rights")
|
|
576
|
+
: createNoopMetricsHandle();
|
|
551
577
|
|
|
552
578
|
// C6 — ohne eigene send*Email-Opts aber mit gemountetem mail-transport
|
|
553
579
|
// versendet der Cron die Export-Notifications selbst (Default-Templates).
|
|
@@ -612,6 +638,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
612
638
|
}),
|
|
613
639
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
614
640
|
actor: ctx.systemUser.id,
|
|
641
|
+
metrics: exportMetrics,
|
|
615
642
|
});
|
|
616
643
|
},
|
|
617
644
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { USER_STATUS, userTable } from "../../user";
|
|
10
|
-
import {
|
|
10
|
+
import { redeemDeletionToken } from "../deletion-token";
|
|
11
11
|
import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
12
12
|
|
|
13
13
|
export type ConfirmDeletionByTokenOptions = {
|
|
@@ -66,24 +66,14 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
66
66
|
agent: { expose: false },
|
|
67
67
|
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
68
68
|
handler: async (event, ctx) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// peekedUserId ist unverifizierte Angreifer-Eingabe — ein Lookup-Fehler
|
|
78
|
-
// (z.B. typfremde id) wird zu demselben generischen 422, nie zu einem 500.
|
|
79
|
-
const requestId = await readPendingDeletionRequestId(ctx, peekedUserId);
|
|
80
|
-
if (!requestId) return writeFailure(invalidToken());
|
|
81
|
-
|
|
82
|
-
const verified = verifyDeletionToken(
|
|
83
|
-
event.payload.token,
|
|
84
|
-
requestId,
|
|
85
|
-
opts.deletionTokenSecret,
|
|
86
|
-
);
|
|
69
|
+
// The row's requestId is part of the verify key, so a token from a
|
|
70
|
+
// cancelled or superseded cycle fails. Every error path ends in the same
|
|
71
|
+
// generic 422.
|
|
72
|
+
const verified = await redeemDeletionToken({
|
|
73
|
+
token: event.payload.token,
|
|
74
|
+
secret: opts.deletionTokenSecret,
|
|
75
|
+
loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
|
|
76
|
+
});
|
|
87
77
|
if (!verified.ok) return writeFailure(invalidToken());
|
|
88
78
|
|
|
89
79
|
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
@@ -95,7 +85,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
95
85
|
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
96
86
|
const res = await startDeletionGracePeriod(
|
|
97
87
|
ctx,
|
|
98
|
-
verified.
|
|
88
|
+
verified.subject,
|
|
99
89
|
profile.profile.userRights.gracePeriod,
|
|
100
90
|
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
101
91
|
);
|
|
@@ -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,
|