@cosmicdrift/kumiko-bundled-features 0.87.3 → 0.89.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 +6 -6
- package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
- package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
- package/src/data-retention/__tests__/resolver.test.ts +3 -3
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
- package/src/data-retention/feature.ts +58 -7
- package/src/data-retention/presets.ts +5 -5
- package/src/data-retention/resolve-for-tenant.ts +9 -4
- package/src/data-retention/resolve-tenant-preset.ts +51 -0
- package/src/data-retention/run-retention-cleanup.ts +151 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
- package/src/legal-pages/feature.ts +22 -10
- package/src/mail-foundation/feature.ts +51 -6
- package/src/mail-foundation/index.ts +2 -0
- package/src/mail-transport-inmemory/feature.ts +6 -3
- package/src/mail-transport-smtp/feature.ts +11 -10
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
- package/src/managed-pages/feature.ts +17 -9
- package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +65 -0
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
- package/src/user-data-rights/email-templates.ts +211 -0
- package/src/user-data-rights/feature.ts +110 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +11 -0
- package/src/user-data-rights/handlers/export-job-detail.query.ts +7 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +8 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
- package/src/user-data-rights/lib/default-mailers.ts +116 -0
- package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
- package/src/user-data-rights/run-export-jobs.ts +19 -8
- package/src/user-data-rights/run-forget-cleanup.ts +11 -1
- package/src/user-data-rights/screens.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -86,11 +86,11 @@
|
|
|
86
86
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
93
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
89
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.89.0",
|
|
90
|
+
"@cosmicdrift/kumiko-framework": "0.89.0",
|
|
91
|
+
"@cosmicdrift/kumiko-headless": "0.89.0",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer": "0.89.0",
|
|
93
|
+
"@cosmicdrift/kumiko-renderer-web": "0.89.0",
|
|
94
94
|
"@mollie/api-client": "^4.5.0",
|
|
95
95
|
"@node-rs/argon2": "^2.0.2",
|
|
96
96
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Beweist dass der retention-cleanup-Cron mit perTenant-Fan-out in der
|
|
2
|
+
// komponierten Registry landet. Ohne perTenant feuert der Cron global einmal,
|
|
3
|
+
// ctx hat keinen Tenant → der Handler returnt sofort → es wird NICHTS
|
|
4
|
+
// bereinigt (silent no-op). r.job geht durch einen anderen Pfad als der
|
|
5
|
+
// synthetische soft-delete-cleanup-Job, deshalb hier explizit gepinnt.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { createDataRetentionFeature } from "../feature";
|
|
10
|
+
|
|
11
|
+
const RETENTION_CLEANUP_JOB = "data-retention:job:retention-cleanup";
|
|
12
|
+
|
|
13
|
+
describe("retention-cleanup cron registration", () => {
|
|
14
|
+
test("perTenant + daily trigger + skip-concurrency ueberleben die Komposition", () => {
|
|
15
|
+
const registry = createRegistry([createDataRetentionFeature()]);
|
|
16
|
+
const job = registry.getJob(RETENTION_CLEANUP_JOB);
|
|
17
|
+
|
|
18
|
+
expect(job).toBeDefined();
|
|
19
|
+
expect(job?.perTenant).toBe(true);
|
|
20
|
+
expect(job?.trigger).toEqual({ cron: "0 3 * * *" });
|
|
21
|
+
expect(job?.concurrency).toBe("skip");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Unit-Test fuer die Preset-Ableitung aus dem Compliance-Profile (Layer 2).
|
|
2
|
+
// Der DB-gebundene "present → derive"-Pfad wird im Integrationstest
|
|
3
|
+
// (retention-cleanup.integration.test.ts) gegen echtes Postgres geprueft;
|
|
4
|
+
// hier nur die pure Map + der Soft-Gate (compliance-profiles nicht gemountet).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { COMPLIANCE_PROFILES } from "@cosmicdrift/kumiko-framework/compliance";
|
|
8
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { RETENTION_PRESETS } from "../presets";
|
|
11
|
+
import { PROFILE_TO_PRESET, resolveTenantRetentionPreset } from "../resolve-tenant-preset";
|
|
12
|
+
|
|
13
|
+
describe("PROFILE_TO_PRESET map", () => {
|
|
14
|
+
test("deckt jeden ComplianceProfileKey ab", () => {
|
|
15
|
+
for (const key of Object.keys(COMPLIANCE_PROFILES)) {
|
|
16
|
+
expect(PROFILE_TO_PRESET[key as keyof typeof PROFILE_TO_PRESET]).toBeDefined();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("mappt nur auf echte RetentionPreset-Keys", () => {
|
|
21
|
+
for (const preset of Object.values(PROFILE_TO_PRESET)) {
|
|
22
|
+
expect(RETENTION_PRESETS[preset]).toBeDefined();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("swiss-dsg ist namensgleich, EU/DE mappen auf ihr Regime", () => {
|
|
27
|
+
expect(PROFILE_TO_PRESET["swiss-dsg"]).toBe("swiss-dsg");
|
|
28
|
+
expect(PROFILE_TO_PRESET["eu-dsgvo"]).toBe("dsgvo-basic");
|
|
29
|
+
expect(PROFILE_TO_PRESET["de-hr-dsgvo-hgb"]).toBe("dsgvo-hgb");
|
|
30
|
+
expect(PROFILE_TO_PRESET["minimal-no-region"]).toBe("default");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveTenantRetentionPreset soft-gate", () => {
|
|
35
|
+
test("compliance-profiles nicht gemountet → null (ohne DB-Zugriff)", async () => {
|
|
36
|
+
let dbTouched = false;
|
|
37
|
+
const db = new Proxy(
|
|
38
|
+
{},
|
|
39
|
+
{
|
|
40
|
+
get() {
|
|
41
|
+
dbTouched = true;
|
|
42
|
+
throw new Error("DB must not be touched when compliance-profiles is absent");
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
) as unknown as DbRunner;
|
|
46
|
+
const registry = { getEntity: () => undefined } as unknown as Registry;
|
|
47
|
+
|
|
48
|
+
const preset = await resolveTenantRetentionPreset({
|
|
49
|
+
db,
|
|
50
|
+
registry,
|
|
51
|
+
tenantId: "t1" as TenantId,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(preset).toBeNull();
|
|
55
|
+
expect(dbTouched).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -188,13 +188,13 @@ describe("resolveRetentionPolicy — Edge-Cases", () => {
|
|
|
188
188
|
|
|
189
189
|
test("Preset hat eintrag, override hat anderen entityName → override greift NUR für seinen entityName", () => {
|
|
190
190
|
const result = resolveRetentionPolicy({
|
|
191
|
-
entityName: "
|
|
191
|
+
entityName: "audit-log",
|
|
192
192
|
entityDef: null,
|
|
193
193
|
tenantPreset: "dsgvo-hgb",
|
|
194
|
-
tenantOverride: null, // kein override für
|
|
194
|
+
tenantOverride: null, // kein override für audit-log
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
// dsgvo-hgb
|
|
197
|
+
// dsgvo-hgb["audit-log"] = 1y / hardDelete
|
|
198
198
|
expect(result.source).toBe("preset");
|
|
199
199
|
expect(result.policy?.keepFor).toBe("1y");
|
|
200
200
|
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// S2.D2b — retention-cleanup gegen echtes Postgres.
|
|
2
|
+
//
|
|
3
|
+
// Verifiziert NICHT nur "alte Rows weg", sondern vor allem die Negativ-Faelle,
|
|
4
|
+
// in denen sich Datenverlust-Bugs verstecken:
|
|
5
|
+
// - Rows INNERHALB der Retention bleiben
|
|
6
|
+
// - Rows eines ANDEREN Tenants bleiben (perTenant-Scope)
|
|
7
|
+
// - Entity OHNE Policy bleibt unangetastet
|
|
8
|
+
// - Policy mit nicht-existenter reference-Spalte → skip statt Mass-Delete
|
|
9
|
+
//
|
|
10
|
+
// Deckt zudem den createdAt→insertedAt-Alias (Boot-Validator erlaubt
|
|
11
|
+
// "createdAt", die Spalte heisst aber inserted_at) und beide aktiven
|
|
12
|
+
// Strategien (hardDelete batched, softDelete).
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
23
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
|
|
24
|
+
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
25
|
+
|
|
26
|
+
// hardDelete, default-reference (createdAt → alias insertedAt → Spalte inserted_at).
|
|
27
|
+
const widgetEntity = createEntity({
|
|
28
|
+
table: "read_c7_widget",
|
|
29
|
+
fields: { label: createTextField({ required: true }) },
|
|
30
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// softDelete-Strategy → setzt is_deleted/deleted_at (Entity ist softDelete).
|
|
34
|
+
const gadgetEntity = createEntity({
|
|
35
|
+
table: "read_c7_gadget",
|
|
36
|
+
softDelete: true,
|
|
37
|
+
fields: { label: createTextField({ required: true }) },
|
|
38
|
+
retention: { keepFor: "30d", strategy: "softDelete" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Keine Policy → darf nie angefasst werden.
|
|
42
|
+
const plainEntity = createEntity({
|
|
43
|
+
table: "read_c7_plain",
|
|
44
|
+
fields: { label: createTextField({ required: true }) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// reference="lastSeenAt" ist Boot-valide (Framework-Timestamp-Allowlist), aber
|
|
48
|
+
// die Spalte existiert auf diesem Entity nicht → Guard muss skippen.
|
|
49
|
+
const staleEntity = createEntity({
|
|
50
|
+
table: "read_c7_stale",
|
|
51
|
+
fields: { label: createTextField({ required: true }) },
|
|
52
|
+
retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const c7Feature = defineFeature("c7-retention-fixtures", (r) => {
|
|
56
|
+
r.entity("c7-widget", widgetEntity);
|
|
57
|
+
r.entity("c7-gadget", gadgetEntity);
|
|
58
|
+
r.entity("c7-plain", plainEntity);
|
|
59
|
+
r.entity("c7-stale", staleEntity);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const T1 = "11111111-1111-1111-1111-111111111111";
|
|
63
|
+
const T2 = "22222222-2222-2222-2222-222222222222";
|
|
64
|
+
|
|
65
|
+
let stack: TestStack;
|
|
66
|
+
let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
|
|
67
|
+
let pastIso: string;
|
|
68
|
+
let withinIso: string;
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
stack = await setupTestStack({ features: [createDataRetentionFeature(), c7Feature] });
|
|
72
|
+
for (const e of [
|
|
73
|
+
tenantRetentionOverrideEntity,
|
|
74
|
+
widgetEntity,
|
|
75
|
+
gadgetEntity,
|
|
76
|
+
plainEntity,
|
|
77
|
+
staleEntity,
|
|
78
|
+
]) {
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, e);
|
|
80
|
+
}
|
|
81
|
+
now = getTemporal().Now.instant();
|
|
82
|
+
pastIso = now.subtract({ hours: 60 * 24 }).toString(); // 60d alt → ueber Cutoff
|
|
83
|
+
withinIso = now.subtract({ hours: 10 * 24 }).toString(); // 10d alt → innerhalb
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
async function seed(table: string, tenantId: string, label: string, insertedAtIso: string) {
|
|
91
|
+
await asRawClient(stack.db).unsafe(
|
|
92
|
+
`INSERT INTO ${table} (tenant_id, label, inserted_at) VALUES ($1, $2, $3::timestamptz)`,
|
|
93
|
+
[tenantId, label, insertedAtIso],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function labels(table: string, tenantId: string): Promise<string[]> {
|
|
98
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
99
|
+
`SELECT label FROM ${table} WHERE tenant_id = $1 ORDER BY label`,
|
|
100
|
+
[tenantId],
|
|
101
|
+
)) as { label: string }[];
|
|
102
|
+
return rows.map((r) => r.label);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function liveGadgetLabels(tenantId: string): Promise<string[]> {
|
|
106
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
107
|
+
`SELECT label FROM read_c7_gadget WHERE tenant_id = $1 AND is_deleted = false ORDER BY label`,
|
|
108
|
+
[tenantId],
|
|
109
|
+
)) as { label: string }[];
|
|
110
|
+
return rows.map((r) => r.label);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
for (const t of ["read_c7_widget", "read_c7_gadget", "read_c7_plain", "read_c7_stale"]) {
|
|
115
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("runRetentionCleanup :: real postgres", () => {
|
|
120
|
+
test("hardDelete entfernt nur abgelaufene Rows DES Tenants, behaelt frische + Fremd-Tenant", async () => {
|
|
121
|
+
await seed("read_c7_widget", T1, "expired-t1", pastIso);
|
|
122
|
+
await seed("read_c7_widget", T1, "fresh-t1", withinIso);
|
|
123
|
+
await seed("read_c7_widget", T2, "expired-t2", pastIso);
|
|
124
|
+
|
|
125
|
+
const result = await runRetentionCleanup({
|
|
126
|
+
db: stack.db,
|
|
127
|
+
registry: stack.registry,
|
|
128
|
+
tenantId: T1,
|
|
129
|
+
tenantPreset: null,
|
|
130
|
+
now,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.hardDeleted).toBe(1);
|
|
134
|
+
expect(await labels("read_c7_widget", T1)).toEqual(["fresh-t1"]); // within bleibt
|
|
135
|
+
expect(await labels("read_c7_widget", T2)).toEqual(["expired-t2"]); // Fremd-Tenant bleibt
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("softDelete markiert abgelaufene Rows, frische bleiben live", async () => {
|
|
139
|
+
await seed("read_c7_gadget", T1, "expired", pastIso);
|
|
140
|
+
await seed("read_c7_gadget", T1, "fresh", withinIso);
|
|
141
|
+
|
|
142
|
+
const result = await runRetentionCleanup({
|
|
143
|
+
db: stack.db,
|
|
144
|
+
registry: stack.registry,
|
|
145
|
+
tenantId: T1,
|
|
146
|
+
tenantPreset: null,
|
|
147
|
+
now,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.softDeleted).toBe(1);
|
|
151
|
+
expect(await liveGadgetLabels(T1)).toEqual(["fresh"]);
|
|
152
|
+
// Row physisch noch da (nur is_deleted=true).
|
|
153
|
+
expect(await labels("read_c7_gadget", T1)).toEqual(["expired", "fresh"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Entity ohne Policy bleibt komplett unangetastet", async () => {
|
|
157
|
+
await seed("read_c7_plain", T1, "ancient", pastIso);
|
|
158
|
+
|
|
159
|
+
await runRetentionCleanup({
|
|
160
|
+
db: stack.db,
|
|
161
|
+
registry: stack.registry,
|
|
162
|
+
tenantId: T1,
|
|
163
|
+
tenantPreset: null,
|
|
164
|
+
now,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(await labels("read_c7_plain", T1)).toEqual(["ancient"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("fehlende reference-Spalte → skip statt Mass-Delete", async () => {
|
|
171
|
+
await seed("read_c7_stale", T1, "should-survive", pastIso);
|
|
172
|
+
|
|
173
|
+
const result = await runRetentionCleanup({
|
|
174
|
+
db: stack.db,
|
|
175
|
+
registry: stack.registry,
|
|
176
|
+
tenantId: T1,
|
|
177
|
+
tenantPreset: null,
|
|
178
|
+
now,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(await labels("read_c7_stale", T1)).toEqual(["should-survive"]);
|
|
182
|
+
expect(result.skipped).toContainEqual({
|
|
183
|
+
entityName: "c7-stale",
|
|
184
|
+
reason: "missing_reference_column",
|
|
185
|
+
});
|
|
186
|
+
expect(result.hardDeleted).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { policyForQuery } from "./handlers/policy-for.query";
|
|
3
|
+
import { resolveTenantRetentionPreset } from "./resolve-tenant-preset";
|
|
4
|
+
import { runRetentionCleanup } from "./run-retention-cleanup";
|
|
3
5
|
import { tenantRetentionOverrideEntity } from "./schema/tenant-retention-override";
|
|
4
6
|
|
|
5
7
|
export { retentionOverrideSchema } from "./override-schema";
|
|
@@ -13,12 +15,22 @@ export {
|
|
|
13
15
|
type ResolveForTenantArgs,
|
|
14
16
|
resolveRetentionPolicyForTenant,
|
|
15
17
|
} from "./resolve-for-tenant";
|
|
18
|
+
export {
|
|
19
|
+
type ResolveTenantPresetArgs,
|
|
20
|
+
resolveTenantRetentionPreset,
|
|
21
|
+
} from "./resolve-tenant-preset";
|
|
16
22
|
export {
|
|
17
23
|
type EffectiveRetentionPolicy,
|
|
18
24
|
type ResolveRetentionPolicyArgs,
|
|
19
25
|
type RetentionOverride,
|
|
20
26
|
resolveRetentionPolicy,
|
|
21
27
|
} from "./resolver";
|
|
28
|
+
export {
|
|
29
|
+
type RetentionCleanupSkip,
|
|
30
|
+
type RunRetentionCleanupArgs,
|
|
31
|
+
type RunRetentionCleanupResult,
|
|
32
|
+
runRetentionCleanup,
|
|
33
|
+
} from "./run-retention-cleanup";
|
|
22
34
|
export {
|
|
23
35
|
tenantRetentionOverrideEntity,
|
|
24
36
|
tenantRetentionOverrideTable,
|
|
@@ -31,11 +43,12 @@ export {
|
|
|
31
43
|
// - Retention-Presets (dsgvo-basic, dsgvo-hgb, swiss-dsg, default)
|
|
32
44
|
// - tenantRetentionOverride-Entity für per-Tenant Edge-Cases
|
|
33
45
|
//
|
|
34
|
-
// Sprint 2.
|
|
35
|
-
// -
|
|
36
|
-
// -
|
|
46
|
+
// Sprint 2.D2b (this commit):
|
|
47
|
+
// - retention-cleanup-Cron (perTenant) — hardDelete (batched) + softDelete
|
|
48
|
+
// - Layer-2-Preset aus dem Compliance-Profile abgeleitet (soft-dep)
|
|
49
|
+
// - anonymize deferred (Idempotenz-Marker, siehe run-retention-cleanup.ts)
|
|
37
50
|
//
|
|
38
|
-
// Sprint 2.D3 (
|
|
51
|
+
// Sprint 2.D3 (done):
|
|
39
52
|
// - r.exposesApi("retention.policyFor") für user-data-rights
|
|
40
53
|
//
|
|
41
54
|
// Cross-Feature-Hinweis: Plan-Roadmap docs/plans/datenschutz/
|
|
@@ -58,8 +71,46 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
58
71
|
r.exposesApi("retention.policyFor");
|
|
59
72
|
r.queryHandler(policyForQuery);
|
|
60
73
|
|
|
61
|
-
// S2.D2b
|
|
62
|
-
//
|
|
63
|
-
//
|
|
74
|
+
// S2.D2b — autonomer Retention-Cleanup. perTenant-Fan-out (ein Run pro
|
|
75
|
+
// aktivem Tenant, wie soft-delete-cleanup): die job-DB ist NICHT
|
|
76
|
+
// tenant-scoped, deshalb scoped der Runner jeden Delete explizit per
|
|
77
|
+
// tenantId. Ohne diesen Cron werden Retention-Regeln zwar konfiguriert,
|
|
78
|
+
// aber nie ausgefuehrt.
|
|
79
|
+
r.job(
|
|
80
|
+
"retention-cleanup",
|
|
81
|
+
{ trigger: { cron: "0 3 * * *" }, perTenant: true, concurrency: "skip" },
|
|
82
|
+
async (_payload, ctx) => {
|
|
83
|
+
if (!ctx.db || !ctx.registry) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"retention-cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
|
|
89
|
+
if (tenantId === undefined) {
|
|
90
|
+
// skip: cron fired without a perTenant fan-out tenant — nothing scoped
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
94
|
+
const cleanupDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
95
|
+
const tenantPreset = await resolveTenantRetentionPreset({
|
|
96
|
+
db: cleanupDb,
|
|
97
|
+
registry: ctx.registry,
|
|
98
|
+
tenantId,
|
|
99
|
+
});
|
|
100
|
+
const result = await runRetentionCleanup({
|
|
101
|
+
db: cleanupDb,
|
|
102
|
+
registry: ctx.registry,
|
|
103
|
+
tenantId,
|
|
104
|
+
tenantPreset,
|
|
105
|
+
now: T.Now.instant(),
|
|
106
|
+
});
|
|
107
|
+
if (result.anonymizeDeferred.length > 0 || result.skipped.length > 0) {
|
|
108
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for deferred/skipped entities
|
|
109
|
+
console.warn(
|
|
110
|
+
`[data-retention:retention-cleanup] tenant=${tenantId} anonymizeDeferred=${result.anonymizeDeferred.join(",") || "-"} skipped=${result.skipped.map((s) => `${s.entityName}:${s.reason}`).join(",") || "-"}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
);
|
|
64
115
|
});
|
|
65
116
|
}
|
|
@@ -34,18 +34,18 @@ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPre
|
|
|
34
34
|
|
|
35
35
|
// DSGVO Basic — Datenminimierung ohne Buchhaltungspflichten.
|
|
36
36
|
"dsgvo-basic": {
|
|
37
|
-
|
|
37
|
+
"audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
38
38
|
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
39
|
-
|
|
39
|
+
"http-log": { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
40
40
|
},
|
|
41
41
|
|
|
42
42
|
// DSGVO + HGB — deutsche Aufbewahrungspflichten überlagert. Order
|
|
43
43
|
// wird anonymisiert (PII raus, Geschäftsdaten bleiben), Invoice +
|
|
44
44
|
// Booking sind blockDelete bis 10 Jahre, dann Anonymize.
|
|
45
45
|
"dsgvo-hgb": {
|
|
46
|
-
|
|
46
|
+
"audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
47
47
|
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
48
|
-
|
|
48
|
+
"http-log": { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
49
49
|
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
50
50
|
booking: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
51
51
|
contract: { keepFor: "6y", strategy: "blockDelete", reference: "createdAt" },
|
|
@@ -54,7 +54,7 @@ export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPre
|
|
|
54
54
|
|
|
55
55
|
// Schweizer DSG — ähnlich DSGVO mit OR Art. 958f Aufbewahrung.
|
|
56
56
|
"swiss-dsg": {
|
|
57
|
-
|
|
57
|
+
"audit-log": { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
58
58
|
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
59
59
|
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
60
60
|
},
|
|
@@ -10,6 +10,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
10
10
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
|
+
import type { RetentionPresetKey } from "./presets";
|
|
13
14
|
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
14
15
|
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
15
16
|
|
|
@@ -18,6 +19,13 @@ export interface ResolveForTenantArgs {
|
|
|
18
19
|
readonly registry: Registry;
|
|
19
20
|
readonly tenantId: TenantId;
|
|
20
21
|
readonly entityName: string;
|
|
22
|
+
/**
|
|
23
|
+
* Layer 2 — Tenant-Preset, vom Caller aufgeloest (retention-cleanup-Cron
|
|
24
|
+
* leitet ihn aus dem Compliance-Profile ab, siehe resolve-tenant-preset.ts).
|
|
25
|
+
* null = kein Preset, Resolver faellt auf Entity-Default (Layer 1) +
|
|
26
|
+
* Tenant-Override (Layer 3) zurueck.
|
|
27
|
+
*/
|
|
28
|
+
readonly tenantPreset?: RetentionPresetKey | null;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export async function resolveRetentionPolicyForTenant(
|
|
@@ -36,13 +44,10 @@ export async function resolveRetentionPolicyForTenant(
|
|
|
36
44
|
|
|
37
45
|
const entityDef = args.registry.getEntity(args.entityName) ?? null;
|
|
38
46
|
|
|
39
|
-
// Layer 2 (Tenant-Preset) kommt mit S2.D2b. Bis dahin null.
|
|
40
|
-
const tenantPreset = null;
|
|
41
|
-
|
|
42
47
|
return resolveRetentionPolicy({
|
|
43
48
|
entityName: args.entityName,
|
|
44
49
|
entityDef,
|
|
45
|
-
tenantPreset,
|
|
50
|
+
tenantPreset: args.tenantPreset ?? null,
|
|
46
51
|
tenantOverride,
|
|
47
52
|
});
|
|
48
53
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Leitet den Layer-2 Retention-Preset eines Tenants aus seinem Compliance-
|
|
2
|
+
// Profile ab. Der retention-cleanup-Cron ruft das pro fan-out-Tenant.
|
|
3
|
+
//
|
|
4
|
+
// **Soft-Dependency:** data-retention bleibt standalone-mountbar. Nur wenn
|
|
5
|
+
// compliance-profiles mit-gemountet ist (seine Entity registriert), wird ein
|
|
6
|
+
// Preset abgeleitet — sonst null, dann greifen ausschliesslich Entity-Defaults
|
|
7
|
+
// (Layer 1) + per-Entity Tenant-Overrides (Layer 3). Kein r.requires, damit
|
|
8
|
+
// eine App data-retention auch ohne Compliance-Profiles nutzen kann.
|
|
9
|
+
//
|
|
10
|
+
// Die Map ist intentional: Compliance-Profile (Region) und Retention-Preset
|
|
11
|
+
// sind beide um dieselben drei Regimes gebaut (EU/CH/DE-HGB). swiss-dsg ist
|
|
12
|
+
// sogar namensgleich. minimal-no-region → "default" (No-Op-Preset).
|
|
13
|
+
|
|
14
|
+
import type { ComplianceProfileKey } from "@cosmicdrift/kumiko-framework/compliance";
|
|
15
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
18
|
+
import type { RetentionPresetKey } from "./presets";
|
|
19
|
+
|
|
20
|
+
// r.entity-Name aus compliance-profiles/feature.ts — Probe ob das Feature
|
|
21
|
+
// gemountet ist, bevor wir seine Tabelle lesen (sonst wirft fetchOne).
|
|
22
|
+
const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
|
|
23
|
+
|
|
24
|
+
const PROFILE_TO_PRESET: Readonly<Record<ComplianceProfileKey, RetentionPresetKey>> = {
|
|
25
|
+
"eu-dsgvo": "dsgvo-basic",
|
|
26
|
+
"de-hr-dsgvo-hgb": "dsgvo-hgb",
|
|
27
|
+
"swiss-dsg": "swiss-dsg",
|
|
28
|
+
"minimal-no-region": "default",
|
|
29
|
+
} satisfies Readonly<Record<ComplianceProfileKey, RetentionPresetKey>>;
|
|
30
|
+
|
|
31
|
+
export interface ResolveTenantPresetArgs {
|
|
32
|
+
readonly db: DbRunner;
|
|
33
|
+
readonly registry: Registry;
|
|
34
|
+
readonly tenantId: TenantId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveTenantRetentionPreset(
|
|
38
|
+
args: ResolveTenantPresetArgs,
|
|
39
|
+
): Promise<RetentionPresetKey | null> {
|
|
40
|
+
if (!args.registry.getEntity(COMPLIANCE_PROFILE_ENTITY)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// resolveProfileForTenant liest die 1:1-Profile-Row des Tenants (boot-DB,
|
|
44
|
+
// by tenantId gefiltert) und faellt auf minimal-no-region zurueck wenn der
|
|
45
|
+
// Tenant noch kein Profile gewaehlt hat.
|
|
46
|
+
const effective = await resolveProfileForTenant({ db: args.db, tenantId: args.tenantId });
|
|
47
|
+
return PROFILE_TO_PRESET[effective.profile.key] ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Exportiert fuer den Unit-Test (Map-Vollstaendigkeit gegen ComplianceProfileKey).
|
|
51
|
+
export { PROFILE_TO_PRESET };
|