@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.2.3
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/CHANGELOG.md +31 -0
- package/package.json +11 -5
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// userData-Hook Integration-Tests (S2.H1+H2).
|
|
2
|
+
//
|
|
3
|
+
// User-Explicit-Checks aus der Sprint-2-Anfrage:
|
|
4
|
+
// - "alle daten enthalten" (Export-Bundle hat user-Profil + fileRefs)
|
|
5
|
+
// - "PII check in daten" (Forget anonymisiert email/displayName,
|
|
6
|
+
// Export-Bundle hat keine passwordHash/roles)
|
|
7
|
+
// - "exporte + fristen, nach loeschfrist sollte es keine daten mehr
|
|
8
|
+
// haben" (Forget mit strategy=delete entfernt PII; tieferer
|
|
9
|
+
// Frist-Test in S2.U5/S2.T1 wenn Cron-Pipeline da)
|
|
10
|
+
// - "cross data matrix checks" (Cross-Tenant-Isolation: Tenant A's
|
|
11
|
+
// fileRef-Forget beruehrt Tenant B's Files nicht)
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { sql } from "drizzle-orm";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
21
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
22
|
+
import { createFilesFeature } from "../../files";
|
|
23
|
+
import {
|
|
24
|
+
createUserFeature,
|
|
25
|
+
USER_ANONYMIZED_DISPLAY_NAME,
|
|
26
|
+
USER_DELETED_DISPLAY_NAME,
|
|
27
|
+
USER_STATUS,
|
|
28
|
+
userEntity,
|
|
29
|
+
userTable,
|
|
30
|
+
} from "../../user";
|
|
31
|
+
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
32
|
+
import { createUserDataRightsDefaultsFeature } from "../feature";
|
|
33
|
+
import { fileRefDeleteHook, fileRefExportHook, userDeleteHook, userExportHook } from "../index";
|
|
34
|
+
|
|
35
|
+
let stack: TestStack;
|
|
36
|
+
|
|
37
|
+
const features = [
|
|
38
|
+
createUserFeature(),
|
|
39
|
+
createFilesFeature(),
|
|
40
|
+
createDataRetentionFeature(),
|
|
41
|
+
createComplianceProfilesFeature(),
|
|
42
|
+
createUserDataRightsFeature(),
|
|
43
|
+
createUserDataRightsDefaultsFeature(),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
stack = await setupTestStack({ features });
|
|
48
|
+
|
|
49
|
+
// userEntity via Framework-Helper migrieren (kennt softDelete +
|
|
50
|
+
// automatische tenant_id-Spalte — die manuell-CREATE wuerde mit
|
|
51
|
+
// Drizzle-Generated-Queries kollidieren).
|
|
52
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
53
|
+
|
|
54
|
+
// file_refs ist framework-pgTable (nicht entity-getrieben, S1.5 hat
|
|
55
|
+
// die Schema-Sicht ohne buildDrizzleTable-Auto-Generation). Manuelle
|
|
56
|
+
// CREATE matched die Spalten aus framework/src/files/file-ref-table.ts
|
|
57
|
+
await stack.db.execute(sql`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
59
|
+
id UUID PRIMARY KEY,
|
|
60
|
+
tenant_id UUID NOT NULL,
|
|
61
|
+
storage_key TEXT NOT NULL,
|
|
62
|
+
file_name TEXT NOT NULL,
|
|
63
|
+
mime_type TEXT NOT NULL,
|
|
64
|
+
size INTEGER NOT NULL,
|
|
65
|
+
entity_type TEXT,
|
|
66
|
+
entity_id TEXT,
|
|
67
|
+
field_name TEXT,
|
|
68
|
+
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
69
|
+
inserted_by_id TEXT
|
|
70
|
+
)
|
|
71
|
+
`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await stack.cleanup();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
79
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
80
|
+
|
|
81
|
+
// fileRef-IDs muessen UUID sein (file_refs.id ist UUID per S0.1+S1.5).
|
|
82
|
+
// Helper baut zaehlerbasierte UUIDs damit Tests deterministisch.
|
|
83
|
+
function uuid(suffix: number): string {
|
|
84
|
+
return `aaaaaaaa-aaaa-4aaa-8aaa-${suffix.toString(16).padStart(12, "0")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function seedUser(id: string, overrides: Record<string, unknown> = {}): Promise<void> {
|
|
88
|
+
// Drizzle-Insert nutzt Schema (incl. framework-managed tenantId-Spalte).
|
|
89
|
+
// user-Entity ist tenant-agnostisch im Domain-Sinn, aber das DB-
|
|
90
|
+
// Schema hat tenant_id-Spalte automatisch (Framework-Default).
|
|
91
|
+
// Pragmatisch: SYSTEM_TENANT_ID fuer User-Rows in Tests.
|
|
92
|
+
const SYSTEM_TENANT = "00000000-0000-4000-8000-000000000001";
|
|
93
|
+
await stack.db
|
|
94
|
+
.insert(userTable)
|
|
95
|
+
.values({
|
|
96
|
+
id,
|
|
97
|
+
tenantId: SYSTEM_TENANT,
|
|
98
|
+
email: `user-${id}@example.com`,
|
|
99
|
+
passwordHash: "hashed-password",
|
|
100
|
+
displayName: `User ${id}`,
|
|
101
|
+
locale: "de",
|
|
102
|
+
emailVerified: true,
|
|
103
|
+
roles: '["Member"]',
|
|
104
|
+
status: USER_STATUS.Active,
|
|
105
|
+
...overrides,
|
|
106
|
+
})
|
|
107
|
+
.onConflictDoNothing();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function seedFileRef(
|
|
111
|
+
id: string,
|
|
112
|
+
tenantId: string,
|
|
113
|
+
insertedById: string | null,
|
|
114
|
+
fileName: string,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
await stack.db.execute(sql`
|
|
117
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
118
|
+
VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${fileName}, 'application/pdf', 1024, ${insertedById})
|
|
119
|
+
ON CONFLICT (id) DO NOTHING
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function fetchUser(id: string) {
|
|
124
|
+
const result = await stack.db.execute(sql`
|
|
125
|
+
SELECT id, email, display_name, password_hash, status, deleted_at
|
|
126
|
+
FROM read_users WHERE id = ${id}
|
|
127
|
+
`);
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute returns any-typed array
|
|
129
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
130
|
+
id: string;
|
|
131
|
+
email: string;
|
|
132
|
+
display_name: string;
|
|
133
|
+
password_hash: string | null;
|
|
134
|
+
status: string;
|
|
135
|
+
deleted_at: string | null;
|
|
136
|
+
}>;
|
|
137
|
+
return rows[0] ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchFileRefs(tenantId: string, insertedById?: string | null) {
|
|
141
|
+
const result =
|
|
142
|
+
insertedById === undefined
|
|
143
|
+
? await stack.db.execute(sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId}`)
|
|
144
|
+
: insertedById === null
|
|
145
|
+
? await stack.db.execute(
|
|
146
|
+
sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId} AND inserted_by_id IS NULL`,
|
|
147
|
+
)
|
|
148
|
+
: await stack.db.execute(
|
|
149
|
+
sql`SELECT * FROM file_refs WHERE tenant_id = ${tenantId} AND inserted_by_id = ${insertedById}`,
|
|
150
|
+
);
|
|
151
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
152
|
+
return (result as any).rows ?? result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("user-data-rights-defaults :: feature loads", () => {
|
|
156
|
+
test("Boot ist clean (5 features in der requires-Chain)", () => {
|
|
157
|
+
expect(stack).toBeDefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("S2.H1 :: userExportHook", () => {
|
|
162
|
+
test("liefert Profil-JSON ohne passwordHash + roles (PII-Check)", async () => {
|
|
163
|
+
await seedUser(uuid(1001), { displayName: "Marc" });
|
|
164
|
+
|
|
165
|
+
const result = await userExportHook({
|
|
166
|
+
db: stack.db,
|
|
167
|
+
tenantId: TENANT_A,
|
|
168
|
+
userId: uuid(1001),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result).toBeDefined();
|
|
172
|
+
expect(result?.entity).toBe("user");
|
|
173
|
+
expect(result?.rows).toHaveLength(1);
|
|
174
|
+
const profile = result?.rows[0];
|
|
175
|
+
expect(String(profile?.["email"])).toContain("@example.com");
|
|
176
|
+
expect(profile?.["displayName"]).toBe("Marc");
|
|
177
|
+
expect(profile?.["locale"]).toBe("de");
|
|
178
|
+
// PII-Check: KEINE passwordHash + roles im Bundle
|
|
179
|
+
expect(profile?.["passwordHash"]).toBeUndefined();
|
|
180
|
+
expect(profile?.["roles"]).toBeUndefined();
|
|
181
|
+
expect(profile?.["status"]).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("returns null wenn User nicht existiert", async () => {
|
|
185
|
+
const result = await userExportHook({
|
|
186
|
+
db: stack.db,
|
|
187
|
+
tenantId: TENANT_A,
|
|
188
|
+
userId: uuid(1002),
|
|
189
|
+
});
|
|
190
|
+
expect(result).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("S2.H1 :: userDeleteHook", () => {
|
|
195
|
+
test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
|
|
196
|
+
await seedUser(uuid(1003));
|
|
197
|
+
|
|
198
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
|
|
199
|
+
|
|
200
|
+
const row = await fetchUser(uuid(1003));
|
|
201
|
+
expect(row).not.toBeNull();
|
|
202
|
+
if (!row) throw new Error("row should exist");
|
|
203
|
+
expect(row.email).toContain("anonymized.invalid"); // PII raus
|
|
204
|
+
expect(row.email).not.toContain("@example.com"); // urspruengliche email weg
|
|
205
|
+
expect(row.display_name).toBe(USER_DELETED_DISPLAY_NAME);
|
|
206
|
+
expect(row.password_hash).toBeNull();
|
|
207
|
+
expect(row.status).toBe(USER_STATUS.Deleted);
|
|
208
|
+
expect(row.deleted_at).not.toBeNull(); // softDelete-Timestamp gesetzt
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
|
|
212
|
+
await seedUser(uuid(1004));
|
|
213
|
+
|
|
214
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
|
|
215
|
+
|
|
216
|
+
const row = await fetchUser(uuid(1004));
|
|
217
|
+
if (!row) throw new Error("row should exist");
|
|
218
|
+
expect(row.email).toContain("anonymized.invalid");
|
|
219
|
+
expect(row.display_name).toBe(USER_ANONYMIZED_DISPLAY_NAME);
|
|
220
|
+
expect(row.status).toBe(USER_STATUS.Active); // NICHT auf deleted
|
|
221
|
+
expect(row.deleted_at).toBeNull(); // KEIN softDelete
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
|
|
225
|
+
await seedUser(uuid(1005));
|
|
226
|
+
|
|
227
|
+
await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
|
|
228
|
+
const afterFirst = await fetchUser(uuid(1005));
|
|
229
|
+
if (!afterFirst) throw new Error("user should exist after first delete");
|
|
230
|
+
|
|
231
|
+
// Zweiter Call: kein Crash + State unverändert
|
|
232
|
+
await expect(
|
|
233
|
+
userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
|
|
234
|
+
).resolves.toBeUndefined();
|
|
235
|
+
|
|
236
|
+
// State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
|
|
237
|
+
// kein Status-Reset, anonymisierte Werte unverändert.
|
|
238
|
+
const afterSecond = await fetchUser(uuid(1005));
|
|
239
|
+
if (!afterSecond) throw new Error("user should exist after second delete");
|
|
240
|
+
expect(afterSecond.status).toBe(USER_STATUS.Deleted);
|
|
241
|
+
expect(afterSecond.display_name).toBe(USER_DELETED_DISPLAY_NAME);
|
|
242
|
+
expect(afterSecond.password_hash).toBeNull();
|
|
243
|
+
expect(afterSecond.email).toBe(afterFirst.email); // gleicher Wert, nicht "neu anonymisiert"
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("S2.H2 :: fileRefExportHook", () => {
|
|
248
|
+
test("liefert FileRef-Metadata + signed-URL-Liste fuer Sprint-2.U3 ZIP-Bau", async () => {
|
|
249
|
+
await seedFileRef(uuid(101), TENANT_A, "user-files-1", "lebenslauf.pdf");
|
|
250
|
+
await seedFileRef(uuid(102), TENANT_A, "user-files-1", "anschreiben.pdf");
|
|
251
|
+
|
|
252
|
+
const result = await fileRefExportHook({
|
|
253
|
+
db: stack.db,
|
|
254
|
+
tenantId: TENANT_A,
|
|
255
|
+
userId: "user-files-1",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result?.entity).toBe("fileRef");
|
|
259
|
+
expect(result?.rows).toHaveLength(2);
|
|
260
|
+
expect(result?.fileRefs).toHaveLength(2);
|
|
261
|
+
const names = result?.fileRefs?.map((f) => f.fileName).sort();
|
|
262
|
+
expect(names).toEqual(["anschreiben.pdf", "lebenslauf.pdf"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("returns null wenn User keine Files hat", async () => {
|
|
266
|
+
const result = await fileRefExportHook({
|
|
267
|
+
db: stack.db,
|
|
268
|
+
tenantId: TENANT_A,
|
|
269
|
+
userId: "ghost-user-no-files",
|
|
270
|
+
});
|
|
271
|
+
expect(result).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
276
|
+
test('strategy="delete" → FileRef-Rows fuer User in Tenant weg', async () => {
|
|
277
|
+
await seedFileRef(uuid(201), TENANT_A, "user-delete-files", "f1.pdf");
|
|
278
|
+
await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
|
|
279
|
+
|
|
280
|
+
await fileRefDeleteHook(
|
|
281
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
|
|
282
|
+
"delete",
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const remaining = await fetchFileRefs(TENANT_A, "user-delete-files");
|
|
286
|
+
expect(remaining).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('strategy="anonymize" → insertedById=null, Files bleiben', async () => {
|
|
290
|
+
await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
|
|
291
|
+
|
|
292
|
+
await fileRefDeleteHook(
|
|
293
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
|
|
294
|
+
"anonymize",
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const ownedAfter = await fetchFileRefs(TENANT_A, "user-anon-files");
|
|
298
|
+
expect(ownedAfter).toHaveLength(0); // keiner mehr mit insertedById=user
|
|
299
|
+
const anonymized = await fetchFileRefs(TENANT_A, null);
|
|
300
|
+
const file = anonymized.find((f: { id: string }) => f.id === uuid(203));
|
|
301
|
+
expect(file).toBeDefined();
|
|
302
|
+
expect(file.inserted_by_id).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("Cross-Tenant-Isolation: Tenant A's Forget beruehrt Tenant B's Files nicht (User-explicit)", async () => {
|
|
306
|
+
await seedFileRef(uuid(301), TENANT_A, "shared-user", "tenantA.pdf");
|
|
307
|
+
await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
|
|
308
|
+
|
|
309
|
+
// Tenant A loescht alle Files von "shared-user"
|
|
310
|
+
await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
|
|
311
|
+
|
|
312
|
+
const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
|
|
313
|
+
const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
|
|
314
|
+
|
|
315
|
+
expect(aRemaining).toHaveLength(0); // Tenant A: weg
|
|
316
|
+
expect(bRemaining).toHaveLength(1); // Tenant B: unangetastet
|
|
317
|
+
expect(bRemaining[0]?.file_name).toBe("tenantB.pdf");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("idempotent: zweiter delete-Call crasht nicht UND DB-State bleibt 0 Files", async () => {
|
|
321
|
+
await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
|
|
322
|
+
|
|
323
|
+
await fileRefDeleteHook(
|
|
324
|
+
{ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
|
|
325
|
+
"delete",
|
|
326
|
+
);
|
|
327
|
+
const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
328
|
+
expect(afterFirst).toHaveLength(0);
|
|
329
|
+
|
|
330
|
+
// Zweiter Call: kein Crash + State weiter 0 Files
|
|
331
|
+
await expect(
|
|
332
|
+
fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
|
|
333
|
+
).resolves.toBeUndefined();
|
|
334
|
+
const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
|
|
335
|
+
expect(afterSecond).toHaveLength(0);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
EXT_USER_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
7
|
+
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
8
|
+
|
|
9
|
+
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
10
|
+
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
11
|
+
//
|
|
12
|
+
// Architektur-Entscheidung (S2.H1+H2): user-data-rights selbst kann
|
|
13
|
+
// nicht r.requires("user", "files") + r.useExtension(EXT_USER_DATA, ...)
|
|
14
|
+
// machen weil es selbst Provider von EXT_USER_DATA ist (Boot-Validator
|
|
15
|
+
// lehnt self-extension ab). Lösung: drittes optional-mountbares Feature
|
|
16
|
+
// das requires beide Sources + die useExtension-Calls macht.
|
|
17
|
+
//
|
|
18
|
+
// App-Author kann dieses Feature weglassen wenn er Custom-Hooks
|
|
19
|
+
// stattdessen registrieren will (z.B. "anonymize sollte den User-Row
|
|
20
|
+
// hard-delete" — App-spezifische Compliance-Entscheidung). Default-
|
|
21
|
+
// Implementierung deckt 95% der Apps ab.
|
|
22
|
+
//
|
|
23
|
+
// Pattern matched file-foundation + file-provider-s3 (separate Plugin-
|
|
24
|
+
// Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
|
|
25
|
+
// das circular-requires waere.
|
|
26
|
+
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
27
|
+
return defineFeature("user-data-rights-defaults", (r) => {
|
|
28
|
+
r.requires("user", "files", "user-data-rights");
|
|
29
|
+
|
|
30
|
+
r.useExtension(EXT_USER_DATA, "user", {
|
|
31
|
+
export: userExportHook,
|
|
32
|
+
delete: userDeleteHook,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
r.useExtension(EXT_USER_DATA, "fileRef", {
|
|
36
|
+
export: fileRefExportHook,
|
|
37
|
+
delete: fileRefDeleteHook,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { and, eq } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
6
|
+
//
|
|
7
|
+
// Export-Hook liefert Metadata aller FileRefs des Users mit Subject-
|
|
8
|
+
// Resolver via insertedById. Storage-Provider-binary-Streams kommen
|
|
9
|
+
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
10
|
+
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
11
|
+
//
|
|
12
|
+
// Delete-Hook entfernt FileRef-Zeile + Storage-Binary. Plan-Roadmap
|
|
13
|
+
// docs/plans/datenschutz/storage-encryption.md hat das Subject-
|
|
14
|
+
// Resolver-Pattern fuer File-Encryption als Sprint 4 — bis dahin:
|
|
15
|
+
// "delete": Row hard-delete + storageProvider.delete() pro File
|
|
16
|
+
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
17
|
+
// koennen weiter zeigen; Personenbezug raus)
|
|
18
|
+
//
|
|
19
|
+
// Storage-Provider-Cleanup ist BEST-EFFORT — wenn S3-delete failt,
|
|
20
|
+
// log + skip (Cron-Job kann es retry). Memory: Forget-Atomicity-
|
|
21
|
+
// Decision aus Sprint-2-Architektur (advisor-pinned): per-Hook
|
|
22
|
+
// idempotent, KEIN globaler Rollback — wenn ein File-Delete failt,
|
|
23
|
+
// bleibt der User-Row trotzdem anonymisiert.
|
|
24
|
+
//
|
|
25
|
+
// Storage-Provider kommt aus dem App-Bootstrap (createBunServer-
|
|
26
|
+
// options.files.storageProvider). Wir greifen darauf via ctx — der
|
|
27
|
+
// Hook-ctx hat aktuell nur db/tenantId/userId, also fuer Storage-
|
|
28
|
+
// Calls braucht es eine Erweiterung. S2.U3 Export-Job-Pipeline regelt
|
|
29
|
+
// das (Job-ctx hat ctx.files.ref(key)). Hier lassen wir Storage-
|
|
30
|
+
// Cleanup als TODO und faellen das in S2.U5 nochmal an.
|
|
31
|
+
|
|
32
|
+
export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
33
|
+
const rawRows = await ctx.db
|
|
34
|
+
.select()
|
|
35
|
+
.from(fileRefsTable)
|
|
36
|
+
.where(
|
|
37
|
+
and(
|
|
38
|
+
eq(fileRefsTable["tenantId"], ctx.tenantId),
|
|
39
|
+
eq(fileRefsTable["insertedById"], ctx.userId),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// @cast-boundary db-row: drizzle liefert insertedAt als Instant
|
|
44
|
+
// (framework-customType). Fuer JSON-Export brauchen wir String —
|
|
45
|
+
// .toString() funktioniert sowohl auf Temporal.Instant als auch
|
|
46
|
+
// Date.
|
|
47
|
+
const rows = rawRows.map((r) => {
|
|
48
|
+
const row = r as Record<string, unknown>;
|
|
49
|
+
return {
|
|
50
|
+
id: String(row["id"]),
|
|
51
|
+
storageKey: String(row["storageKey"]),
|
|
52
|
+
fileName: String(row["fileName"]),
|
|
53
|
+
mimeType: String(row["mimeType"]),
|
|
54
|
+
size: typeof row["size"] === "number" ? row["size"] : 0,
|
|
55
|
+
insertedAt: String(row["insertedAt"] ?? ""),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (rows.length === 0) return null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
entity: "fileRef",
|
|
63
|
+
rows: rows.map((r) => ({
|
|
64
|
+
id: r.id,
|
|
65
|
+
fileName: r.fileName,
|
|
66
|
+
mimeType: r.mimeType,
|
|
67
|
+
size: r.size,
|
|
68
|
+
insertedAt: r.insertedAt,
|
|
69
|
+
})),
|
|
70
|
+
// Plus die fileRefs-Liste die Sprint-2-U3 dann zum Storage-Provider
|
|
71
|
+
// bringt + signed-URLs erzeugt + ins ZIP packt (siehe S1.9-Z1
|
|
72
|
+
// UserDataExportSnippet.fileRefs).
|
|
73
|
+
fileRefs: rows.map((r) => ({
|
|
74
|
+
fileRefId: r.id,
|
|
75
|
+
storageKey: r.storageKey,
|
|
76
|
+
fileName: r.fileName,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
82
|
+
if (strategy === "delete") {
|
|
83
|
+
// Hard-delete der FileRef-Rows fuer diesen User in diesem Tenant.
|
|
84
|
+
// Storage-Binary-Cleanup folgt in S2.U5 wenn der Forget-Job-Ctx
|
|
85
|
+
// den Storage-Provider exposed.
|
|
86
|
+
await ctx.db
|
|
87
|
+
.delete(fileRefsTable)
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
eq(fileRefsTable["tenantId"], ctx.tenantId),
|
|
91
|
+
eq(fileRefsTable["insertedById"], ctx.userId),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
// anonymize: insertedById=null, FileRef + binary bleiben.
|
|
96
|
+
// Use-case: shared chat-Attachment in einem Multi-User-Channel —
|
|
97
|
+
// Author-Identifikation raus, Datei bleibt fuer andere User
|
|
98
|
+
// sichtbar.
|
|
99
|
+
await ctx.db
|
|
100
|
+
.update(fileRefsTable)
|
|
101
|
+
.set({ insertedById: null })
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(fileRefsTable["tenantId"], ctx.tenantId),
|
|
105
|
+
eq(fileRefsTable["insertedById"], ctx.userId),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq, sql } from "drizzle-orm";
|
|
4
|
+
import {
|
|
5
|
+
USER_ANONYMIZED_DISPLAY_NAME,
|
|
6
|
+
USER_ANONYMIZED_EMAIL_DOMAIN,
|
|
7
|
+
USER_ANONYMIZED_EMAIL_PREFIX,
|
|
8
|
+
USER_DELETED_DISPLAY_NAME,
|
|
9
|
+
USER_DELETED_EMAIL_PREFIX,
|
|
10
|
+
USER_STATUS,
|
|
11
|
+
userTable,
|
|
12
|
+
} from "../../user";
|
|
13
|
+
|
|
14
|
+
// userData-Hook fuer user-entity (S2.H1).
|
|
15
|
+
//
|
|
16
|
+
// Export-Hook liefert Profil-Daten ohne security-sensitive Felder
|
|
17
|
+
// (passwordHash, roles, status — DSGVO Art. 20 ist Datenportabilitaet
|
|
18
|
+
// fuer das was der User selbst zur Verfuegung gestellt hat, nicht
|
|
19
|
+
// Lifecycle-Metadata oder Authorization-State).
|
|
20
|
+
//
|
|
21
|
+
// Delete-Hook anonymisiert PII + setzt status=deleted. Der eigentliche
|
|
22
|
+
// softDelete-Flag wird ueber drizzle's deletedAt-Column gesetzt; die
|
|
23
|
+
// Row bleibt fuer Audit-Trail erhalten (alle FK-Refs auf user.id
|
|
24
|
+
// bleiben gueltig). DSGVO-konform via PII-Anonymisierung.
|
|
25
|
+
//
|
|
26
|
+
// Strategy:
|
|
27
|
+
// "delete": softDelete + email/displayName/passwordHash leeren,
|
|
28
|
+
// status=deleted (Login geblockt)
|
|
29
|
+
// "anonymize": email/displayName auf Pseudonym, Row bleibt active
|
|
30
|
+
// — fuer Cases wo User-Row als FK noch relevant ist
|
|
31
|
+
// aber PII raus muss (z.B. anonymize mit blockDelete-
|
|
32
|
+
// Frist auf einer FK-target-Entity)
|
|
33
|
+
|
|
34
|
+
export const userExportHook: UserDataExportHook = async (ctx) => {
|
|
35
|
+
const row = (await fetchOne(ctx.db, userTable, eq(userTable["id"], ctx.userId))) as {
|
|
36
|
+
id: string;
|
|
37
|
+
email: string;
|
|
38
|
+
displayName: string;
|
|
39
|
+
locale: string;
|
|
40
|
+
emailVerified: boolean;
|
|
41
|
+
} | null;
|
|
42
|
+
|
|
43
|
+
if (!row) return null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
entity: "user",
|
|
47
|
+
rows: [
|
|
48
|
+
{
|
|
49
|
+
id: row.id,
|
|
50
|
+
email: row.email,
|
|
51
|
+
displayName: row.displayName,
|
|
52
|
+
locale: row.locale,
|
|
53
|
+
emailVerified: row.emailVerified,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
60
|
+
// Idempotent: zweiter Call findet die Row schon anonymized + skipt
|
|
61
|
+
// implicit (UPDATE mit gleichen Werten). Memory feedback_event_store_
|
|
62
|
+
// tenant_consistency: ctx.tenantId muss in der user-table-Zeile
|
|
63
|
+
// korrelieren — user.id ist tenant-agnostic (User kann in mehreren
|
|
64
|
+
// Tenants Member sein), kein tenantId-Filter noetig.
|
|
65
|
+
|
|
66
|
+
if (strategy === "delete") {
|
|
67
|
+
await ctx.db
|
|
68
|
+
.update(userTable)
|
|
69
|
+
.set({
|
|
70
|
+
email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
71
|
+
displayName: USER_DELETED_DISPLAY_NAME,
|
|
72
|
+
passwordHash: null,
|
|
73
|
+
status: USER_STATUS.Deleted,
|
|
74
|
+
deletedAt: sql`now()`,
|
|
75
|
+
})
|
|
76
|
+
.where(eq(userTable["id"], ctx.userId));
|
|
77
|
+
} else {
|
|
78
|
+
// anonymize: PII raus, aber Row bleibt active (damit FK-References
|
|
79
|
+
// weiter aufloesbar sind). Account ist effektiv weiter nutzbar
|
|
80
|
+
// wenn der User sich neu authentifiziert — pragmatisch akzeptabel
|
|
81
|
+
// weil "anonymize" auf user-entity ein seltener Edge-Case ist
|
|
82
|
+
// (typisch hard-delete fuer User).
|
|
83
|
+
await ctx.db
|
|
84
|
+
.update(userTable)
|
|
85
|
+
.set({
|
|
86
|
+
email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
|
|
87
|
+
displayName: USER_ANONYMIZED_DISPLAY_NAME,
|
|
88
|
+
})
|
|
89
|
+
.where(eq(userTable["id"], ctx.userId));
|
|
90
|
+
}
|
|
91
|
+
};
|