@cosmicdrift/kumiko-bundled-features 0.82.0 → 0.83.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/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +201 -0
- package/src/user-data-rights/constants.ts +7 -0
- package/src/user-data-rights/feature.ts +24 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +8 -0
- package/src/user-data-rights/lib/resolve-tenant-model.ts +34 -0
- package/src/user-data-rights/run-forget-cleanup.ts +37 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.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>",
|
|
@@ -85,11 +85,11 @@
|
|
|
85
85
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
88
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.83.0",
|
|
89
|
+
"@cosmicdrift/kumiko-framework": "0.83.0",
|
|
90
|
+
"@cosmicdrift/kumiko-headless": "0.83.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer": "0.83.0",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer-web": "0.83.0",
|
|
93
93
|
"@mollie/api-client": "^4.5.0",
|
|
94
94
|
"@node-rs/argon2": "^2.0.2",
|
|
95
95
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Configurable tenant-occupancy model for GDPR forget (Art. 17).
|
|
2
|
+
//
|
|
3
|
+
// A tenant-scoped contributor (e.g. credit) has no per-user column to anonymize,
|
|
4
|
+
// so per-user erasure of tenant data is only safe when the tenant has exactly
|
|
5
|
+
// one user. The app declares that via the `tenantModel` config; the forget
|
|
6
|
+
// pipeline refines it per-tenant with a runtime sole-member check before handing
|
|
7
|
+
// `ctx.tenantModel` to each delete-hook.
|
|
8
|
+
//
|
|
9
|
+
// This drives the REAL config resolution (appOverride → resolveAppTenantModel)
|
|
10
|
+
// and the REAL forget pipeline (sole-member refinement → ctx.tenantModel →
|
|
11
|
+
// contributor delete) — NOT a hand-set ctx, which would prove the hook's `if`
|
|
12
|
+
// but not that the config string + system-scope resolution actually carry the
|
|
13
|
+
// value (the failure mode that shipped the ctx.config export bug).
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import {
|
|
18
|
+
createEntity,
|
|
19
|
+
createTextField,
|
|
20
|
+
defineFeature,
|
|
21
|
+
EXT_USER_DATA,
|
|
22
|
+
SYSTEM_USER_ID,
|
|
23
|
+
type UserDataDeleteHook,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
|
+
import {
|
|
27
|
+
resetEventStore,
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
33
|
+
import { createConfigFeature } from "../../config";
|
|
34
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
35
|
+
import { configValueEntity } from "../../config/table";
|
|
36
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
37
|
+
import { createSessionsFeature } from "../../sessions";
|
|
38
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
39
|
+
import { TENANT_MODEL_CONFIG_KEY } from "../constants";
|
|
40
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
41
|
+
import { resolveAppTenantModel } from "../lib/resolve-tenant-model";
|
|
42
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
43
|
+
import {
|
|
44
|
+
createForgetSeeders,
|
|
45
|
+
nowInstant,
|
|
46
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
47
|
+
} from "./forget-test-helpers";
|
|
48
|
+
|
|
49
|
+
const TENANT = "00000000-0000-4000-8000-0000000000c1";
|
|
50
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000c1";
|
|
51
|
+
const CO_MEMBER = "cccccccc-cccc-4ccc-8ccc-0000000000c2";
|
|
52
|
+
const TABLE = "read_dsgvo_tenant_scoped";
|
|
53
|
+
|
|
54
|
+
// Tenant-scoped contributor with NO per-user column — deletes by tenant only,
|
|
55
|
+
// and ONLY when this tenant is effectively single-user (mirrors credit).
|
|
56
|
+
const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
57
|
+
if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
|
|
58
|
+
await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const scopedEntity = createEntity({
|
|
62
|
+
table: TABLE,
|
|
63
|
+
fields: { name: createTextField({ required: true }) },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
|
|
67
|
+
r.entity("tenant-scoped", scopedEntity);
|
|
68
|
+
r.useExtension(EXT_USER_DATA, "tenant-scoped", {
|
|
69
|
+
export: async () => null,
|
|
70
|
+
delete: tenantScopedDeleteHook,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let stack: TestStack;
|
|
75
|
+
const seed = (db: unknown) =>
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this contributor has no binaries.
|
|
77
|
+
createForgetSeeders(db as any, { write: async () => {} });
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
stack = await setupTestStack({
|
|
81
|
+
features: [
|
|
82
|
+
createUserFeature(),
|
|
83
|
+
createSessionsFeature(),
|
|
84
|
+
createDataRetentionFeature(),
|
|
85
|
+
createComplianceProfilesFeature(),
|
|
86
|
+
createConfigFeature(),
|
|
87
|
+
createUserDataRightsFeature(),
|
|
88
|
+
contributorFeature,
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
92
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
93
|
+
await unsafeCreateEntityTable(stack.db, configValueEntity);
|
|
94
|
+
await unsafeCreateEntityTable(stack.db, scopedEntity);
|
|
95
|
+
await createEventsTable(stack.db);
|
|
96
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await stack.cleanup();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
await resetEventStore(stack);
|
|
105
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${TABLE}`);
|
|
106
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
|
|
107
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_users`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function seedScopedRow(rowId: string): Promise<void> {
|
|
111
|
+
await asRawClient(stack.db).unsafe(
|
|
112
|
+
`INSERT INTO ${TABLE} (id, tenant_id, name) VALUES ($1, $2, 'loan')`,
|
|
113
|
+
[rowId, TENANT],
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function rowCount(): Promise<number> {
|
|
118
|
+
const rows = await asRawClient(stack.db).unsafe(`SELECT id FROM ${TABLE} WHERE tenant_id = $1`, [
|
|
119
|
+
TENANT,
|
|
120
|
+
]);
|
|
121
|
+
return (rows as ReadonlyArray<unknown>).length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("tenant-model config resolution (seam)", () => {
|
|
125
|
+
test("appOverride single-user resolves through the real config resolver", async () => {
|
|
126
|
+
const model = await resolveAppTenantModel({
|
|
127
|
+
registry: stack.registry,
|
|
128
|
+
configResolver: createConfigResolver({
|
|
129
|
+
appOverrides: new Map([[TENANT_MODEL_CONFIG_KEY, "single-user"]]),
|
|
130
|
+
}),
|
|
131
|
+
db: stack.db,
|
|
132
|
+
userId: SYSTEM_USER_ID,
|
|
133
|
+
});
|
|
134
|
+
expect(model).toBe("single-user");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("no override falls back to the feature default (multi-user)", async () => {
|
|
138
|
+
const model = await resolveAppTenantModel({
|
|
139
|
+
registry: stack.registry,
|
|
140
|
+
configResolver: createConfigResolver({ appOverrides: new Map() }),
|
|
141
|
+
db: stack.db,
|
|
142
|
+
userId: SYSTEM_USER_ID,
|
|
143
|
+
});
|
|
144
|
+
expect(model).toBe("multi-user");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("forget pipeline honours the effective tenant model", () => {
|
|
149
|
+
test("single-user + sole member → tenant-scoped rows erased", async () => {
|
|
150
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c1");
|
|
151
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
152
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
153
|
+
|
|
154
|
+
const result = await runForgetCleanup({
|
|
155
|
+
db: stack.db,
|
|
156
|
+
registry: stack.registry,
|
|
157
|
+
now: nowInstant(),
|
|
158
|
+
tenantModel: "single-user",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.errors).toHaveLength(0);
|
|
162
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
163
|
+
expect(await rowCount()).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("single-user but a co-member exists → rows preserved (sole-member guard)", async () => {
|
|
167
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c2");
|
|
168
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
169
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
170
|
+
await seed(stack.db).seedMembership(CO_MEMBER, TENANT);
|
|
171
|
+
|
|
172
|
+
const result = await runForgetCleanup({
|
|
173
|
+
db: stack.db,
|
|
174
|
+
registry: stack.registry,
|
|
175
|
+
now: nowInstant(),
|
|
176
|
+
tenantModel: "single-user",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result.errors).toHaveLength(0);
|
|
180
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
181
|
+
// A stray invite made the config's claim false at runtime — the co-member's
|
|
182
|
+
// loan book must survive even though the user was forgotten.
|
|
183
|
+
expect(await rowCount()).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("multi-user → tenant-scoped rows preserved", async () => {
|
|
187
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c3");
|
|
188
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
189
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
190
|
+
|
|
191
|
+
const result = await runForgetCleanup({
|
|
192
|
+
db: stack.db,
|
|
193
|
+
registry: stack.registry,
|
|
194
|
+
now: nowInstant(),
|
|
195
|
+
tenantModel: "multi-user",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result.errors).toHaveLength(0);
|
|
199
|
+
expect(await rowCount()).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -16,6 +16,13 @@ export const UserDataRightsQueries = {
|
|
|
16
16
|
downloadByJob: "user-data-rights:query:download-by-job",
|
|
17
17
|
} as const;
|
|
18
18
|
|
|
19
|
+
// App-level config: tenant-occupancy model. "single-user" tells the forget
|
|
20
|
+
// pipeline that each tenant has exactly one user, so tenant-scoped contributors
|
|
21
|
+
// (e.g. credit) may erase the tenant's data as that user's personal data.
|
|
22
|
+
// Default "multi-user" (declared in feature.ts). Apps set it via appOverrides:
|
|
23
|
+
// `overrides.set(TENANT_MODEL_CONFIG_KEY, "single-user")`.
|
|
24
|
+
export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model" as const;
|
|
25
|
+
|
|
19
26
|
export const UserDataRightsHandlers = {
|
|
20
27
|
requestExport: "user-data-rights:write:request-export",
|
|
21
28
|
requestDeletion: "user-data-rights:write:request-deletion",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createSystemConfig,
|
|
2
3
|
defineFeature,
|
|
3
4
|
EXT_USER_DATA,
|
|
4
5
|
type FeatureDefinition,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
import { requestExportWrite } from "./handlers/request-export.write";
|
|
25
26
|
import { restrictAccountWrite } from "./handlers/restrict-account.write";
|
|
26
27
|
import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
|
|
28
|
+
import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
|
|
27
29
|
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
|
|
28
30
|
import {
|
|
29
31
|
runExportJobs,
|
|
@@ -126,6 +128,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
126
128
|
|
|
127
129
|
r.extendsRegistrar(EXT_USER_DATA, {});
|
|
128
130
|
|
|
131
|
+
// App-level tenant-occupancy model. Default "multi-user" → tenant-scoped
|
|
132
|
+
// contributors (e.g. credit) NEVER erase tenant data on a per-user forget
|
|
133
|
+
// (would harm co-members). An app with one user per tenant sets this to
|
|
134
|
+
// "single-user" via appOverrides (TENANT_MODEL_CONFIG_KEY) so the forget
|
|
135
|
+
// pipeline may erase the tenant's data as that user's personal data — still
|
|
136
|
+
// gated by a runtime sole-member check in run-forget-cleanup.
|
|
137
|
+
r.config({
|
|
138
|
+
keys: {
|
|
139
|
+
tenantModel: createSystemConfig("select", {
|
|
140
|
+
default: "multi-user",
|
|
141
|
+
options: ["single-user", "multi-user"],
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
129
146
|
// S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
|
|
130
147
|
r.entity("export-job", exportJobEntity);
|
|
131
148
|
|
|
@@ -321,10 +338,17 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
321
338
|
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
322
339
|
const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
|
|
323
340
|
const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
341
|
+
const tenantModel = await resolveAppTenantModel({
|
|
342
|
+
registry: ctx.registry,
|
|
343
|
+
configResolver: ctx.configResolver,
|
|
344
|
+
db: forgetDb,
|
|
345
|
+
userId: forgetUserId,
|
|
346
|
+
});
|
|
324
347
|
await runForgetCleanup({
|
|
325
348
|
db: forgetDb,
|
|
326
349
|
registry: ctx.registry,
|
|
327
350
|
now: T.Now.instant(),
|
|
351
|
+
tenantModel,
|
|
328
352
|
// Same per-tenant provider resolution as the export cron — forget
|
|
329
353
|
// deletes binaries from the store upload + export use.
|
|
330
354
|
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
@@ -15,6 +15,7 @@ import { access, defineWriteHandler, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-
|
|
|
15
15
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
16
16
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
+
import { resolveAppTenantModel } from "../lib/resolve-tenant-model";
|
|
18
19
|
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
19
20
|
import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
|
|
20
21
|
|
|
@@ -45,10 +46,17 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
45
46
|
// them, so a row-only delete here would permanently leak the binaries.
|
|
46
47
|
// Resolve through the same file-foundation path the cron uses.
|
|
47
48
|
const forgetDb = ctx.db.raw as DbConnection; // @cast-boundary db-operator: config reads tolerate the outer tx
|
|
49
|
+
const tenantModel = await resolveAppTenantModel({
|
|
50
|
+
registry: ctx.registry,
|
|
51
|
+
configResolver: ctx.configResolver,
|
|
52
|
+
db: forgetDb,
|
|
53
|
+
userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
54
|
+
});
|
|
48
55
|
const result = await runForgetCleanup({
|
|
49
56
|
db: ctx.db.raw,
|
|
50
57
|
registry: ctx.registry,
|
|
51
58
|
now: T.Now.instant(),
|
|
59
|
+
tenantModel,
|
|
52
60
|
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
53
61
|
registry: ctx.registry,
|
|
54
62
|
configResolver: ctx.configResolver,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Resolves the app-level `tenantModel` config once for a forget run. The
|
|
2
|
+
// forget cron + manual handler both call this and pass the scalar into the
|
|
3
|
+
// pure pipeline (which refines it per-tenant with a sole-member check). The key
|
|
4
|
+
// is system-scoped, so the tenantId used for resolution is irrelevant —
|
|
5
|
+
// SYSTEM_TENANT_ID reads the system/appOverride value.
|
|
6
|
+
|
|
7
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import {
|
|
9
|
+
type ConfigResolver,
|
|
10
|
+
type Registry,
|
|
11
|
+
SYSTEM_TENANT_ID,
|
|
12
|
+
type TenantUserModel,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createConfigAccessor } from "../../config";
|
|
15
|
+
import { TENANT_MODEL_CONFIG_KEY } from "../constants";
|
|
16
|
+
|
|
17
|
+
export async function resolveAppTenantModel(args: {
|
|
18
|
+
readonly registry: Registry;
|
|
19
|
+
readonly configResolver: ConfigResolver | undefined;
|
|
20
|
+
readonly db: DbConnection;
|
|
21
|
+
readonly userId: string;
|
|
22
|
+
}): Promise<TenantUserModel> {
|
|
23
|
+
// No resolver (e.g. a unit context) → safe default: never erase tenant-scoped data.
|
|
24
|
+
if (!args.configResolver) return "multi-user";
|
|
25
|
+
const config = createConfigAccessor(
|
|
26
|
+
args.registry,
|
|
27
|
+
args.configResolver,
|
|
28
|
+
SYSTEM_TENANT_ID,
|
|
29
|
+
args.userId,
|
|
30
|
+
args.db,
|
|
31
|
+
);
|
|
32
|
+
const raw = await config(TENANT_MODEL_CONFIG_KEY);
|
|
33
|
+
return raw === "single-user" ? "single-user" : "multi-user";
|
|
34
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
EXT_USER_DATA_ORDER,
|
|
40
40
|
type Registry,
|
|
41
41
|
type TenantId,
|
|
42
|
+
type TenantUserModel,
|
|
42
43
|
type UserDataDeleteHook,
|
|
43
44
|
type UserDataDeleteStrategy,
|
|
44
45
|
type UserDataStorageProvider,
|
|
@@ -96,6 +97,15 @@ export interface RunForgetCleanupArgs {
|
|
|
96
97
|
readonly buildStorageProvider?: (
|
|
97
98
|
tenantId: TenantId,
|
|
98
99
|
) => Promise<UserDataStorageProvider | undefined>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* App-level tenant-occupancy model (resolved from the `tenantModel` config by
|
|
103
|
+
* the cron/handler). The pipeline refines it PER TENANT with a sole-member
|
|
104
|
+
* check before handing `tenantModel` to each delete-hook, so tenant-scoped
|
|
105
|
+
* erasure only happens where it's actually safe. Omitted → `"multi-user"`
|
|
106
|
+
* (no tenant-scoped erasure).
|
|
107
|
+
*/
|
|
108
|
+
readonly tenantModel?: TenantUserModel;
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
export interface ForgetCleanupError {
|
|
@@ -131,6 +141,7 @@ export async function runForgetCleanup(
|
|
|
131
141
|
args: RunForgetCleanupArgs,
|
|
132
142
|
): Promise<RunForgetCleanupResult> {
|
|
133
143
|
const { db, registry, now, sendDeletionExecutedEmail, buildStorageProvider } = args;
|
|
144
|
+
const appTenantModel: TenantUserModel = args.tenantModel ?? "multi-user";
|
|
134
145
|
|
|
135
146
|
// Step 1: Find users with expired grace period.
|
|
136
147
|
const dueUsers = await selectUsersDueForForgetCleanup(
|
|
@@ -173,6 +184,7 @@ export async function runForgetCleanup(
|
|
|
173
184
|
userId: user.id,
|
|
174
185
|
hookEntries,
|
|
175
186
|
buildStorageProvider,
|
|
187
|
+
appTenantModel,
|
|
176
188
|
});
|
|
177
189
|
hookCallsAttempted += userResult.hookCallsAttempted;
|
|
178
190
|
errors.push(...userResult.errors);
|
|
@@ -229,8 +241,9 @@ async function processUser(args: {
|
|
|
229
241
|
userId: string;
|
|
230
242
|
hookEntries: readonly HookEntry[];
|
|
231
243
|
buildStorageProvider?: (tenantId: TenantId) => Promise<UserDataStorageProvider | undefined>;
|
|
244
|
+
appTenantModel: TenantUserModel;
|
|
232
245
|
}): Promise<ProcessUserResult> {
|
|
233
|
-
const { db, registry, userId, hookEntries, buildStorageProvider } = args;
|
|
246
|
+
const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel } = args;
|
|
234
247
|
const errors: ForgetCleanupError[] = [];
|
|
235
248
|
let hookCallsAttempted = 0;
|
|
236
249
|
|
|
@@ -276,6 +289,10 @@ async function processUser(args: {
|
|
|
276
289
|
await runInSubTransaction(db, async (tx) => {
|
|
277
290
|
for (const tenantId of tenantList) {
|
|
278
291
|
currentTenantId = tenantId;
|
|
292
|
+
// Refine the app-level model to THIS tenant: "single-user" only if the
|
|
293
|
+
// tenant truly has one member, so a stray invite can't let a per-user
|
|
294
|
+
// forget erase a co-member's tenant-scoped data (money-path safety).
|
|
295
|
+
const tenantModel = await resolveEffectiveTenantModel(tx, tenantId, appTenantModel);
|
|
279
296
|
for (const entry of hookEntries) {
|
|
280
297
|
currentEntityName = entry.entityName;
|
|
281
298
|
const policy = await resolveRetentionPolicyForTenant({
|
|
@@ -287,7 +304,10 @@ async function processUser(args: {
|
|
|
287
304
|
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
288
305
|
|
|
289
306
|
hookCallsAttempted++;
|
|
290
|
-
await entry.deleteHook(
|
|
307
|
+
await entry.deleteHook(
|
|
308
|
+
{ db: tx, tenantId, userId, buildStorageProvider, tenantModel },
|
|
309
|
+
strategy,
|
|
310
|
+
);
|
|
291
311
|
}
|
|
292
312
|
}
|
|
293
313
|
|
|
@@ -362,6 +382,21 @@ async function runInSubTransaction(
|
|
|
362
382
|
// User-Row und ignorieren tenantId.
|
|
363
383
|
const SYSTEM_TENANT_ID_FOR_ORPHANS = "00000000-0000-0000-0000-000000000000" as TenantId;
|
|
364
384
|
|
|
385
|
+
// "single-user" requires BOTH the app config AND a runtime sole-member check —
|
|
386
|
+
// the config asserts the deployment model, the count guards against a stray
|
|
387
|
+
// invite that would make a per-user forget delete a co-member's tenant-scoped
|
|
388
|
+
// data. Only queried when the app opted into "single-user" (multi-user apps
|
|
389
|
+
// never reach the destructive path, so no extra query for them).
|
|
390
|
+
async function resolveEffectiveTenantModel(
|
|
391
|
+
db: DbRunner,
|
|
392
|
+
tenantId: TenantId,
|
|
393
|
+
appTenantModel: TenantUserModel,
|
|
394
|
+
): Promise<TenantUserModel> {
|
|
395
|
+
if (appTenantModel !== "single-user") return "multi-user";
|
|
396
|
+
const members = await selectMany<{ userId: string }>(db, tenantMembershipsTable, { tenantId });
|
|
397
|
+
return members.length === 1 ? "single-user" : "multi-user";
|
|
398
|
+
}
|
|
399
|
+
|
|
365
400
|
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|
|
366
401
|
// - "anonymize" / "blockDelete" → "anonymize" (Aufbewahrungs-Pflicht
|
|
367
402
|
// blockDelete: Daten muessen physisch bleiben, nur Personen-Bezug raus)
|