@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.25.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/lib/field-access.ts +4 -0
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// DSGVO forget hook-ordering — the custom-fields anonymize strip must run
|
|
2
|
+
// before any host hook that nulls the owner column it filters on, regardless
|
|
3
|
+
// of feature registration order.
|
|
4
|
+
//
|
|
5
|
+
// Two host entities are wired with the OPPOSITE EXT_USER_DATA registration
|
|
6
|
+
// order: one registers the custom-fields strip first (the order that happened
|
|
7
|
+
// to be safe), the other registers the owner-nulling host hook first (the
|
|
8
|
+
// order that exposed the bug). Both run through the real `runForgetCleanup`
|
|
9
|
+
// with strategy=anonymize. With the order-sentinel fix both strip the
|
|
10
|
+
// sensitive jsonb key; without it the host-first entity would leave it behind
|
|
11
|
+
// (the strip matches 0 rows after the owner column is nulled).
|
|
12
|
+
//
|
|
13
|
+
// This drives the full runner on purpose: the existing custom-fields
|
|
14
|
+
// user-data-rights test invokes the strip hook in isolation
|
|
15
|
+
// (getExtensionUsages + direct call) and is structurally blind to ordering.
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import {
|
|
21
|
+
createEntity,
|
|
22
|
+
createTextField,
|
|
23
|
+
defineFeature,
|
|
24
|
+
EXT_USER_DATA,
|
|
25
|
+
type UserDataDeleteHook,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
28
|
+
import {
|
|
29
|
+
createTestUser,
|
|
30
|
+
resetEventStore,
|
|
31
|
+
setupTestStack,
|
|
32
|
+
type TestStack,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { fieldDefinitionEntity } from "../../custom-fields/entity";
|
|
37
|
+
import { createCustomFieldsFeature } from "../../custom-fields/feature";
|
|
38
|
+
import { customFieldsField } from "../../custom-fields/wire-for-entity";
|
|
39
|
+
import { wireCustomFieldsUserDataRightsFor } from "../../custom-fields/wire-user-data-rights";
|
|
40
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
41
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
42
|
+
import { createSessionsFeature } from "../../sessions";
|
|
43
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
44
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
45
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
46
|
+
import {
|
|
47
|
+
createForgetSeeders,
|
|
48
|
+
nowInstant,
|
|
49
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
50
|
+
} from "./forget-test-helpers";
|
|
51
|
+
|
|
52
|
+
const TENANT = "00000000-0000-4000-8000-0000000000aa";
|
|
53
|
+
|
|
54
|
+
// Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
|
|
55
|
+
// clears the owner" pattern — same shape as file-ref/user host hooks).
|
|
56
|
+
function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
|
|
57
|
+
return async (ctx, strategy) => {
|
|
58
|
+
if (strategy === "delete") {
|
|
59
|
+
await asRawClient(ctx.db).unsafe(
|
|
60
|
+
`DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
61
|
+
[ctx.userId, ctx.tenantId],
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await asRawClient(ctx.db).unsafe(
|
|
66
|
+
`UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
67
|
+
[ctx.userId, ctx.tenantId],
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HostSpec {
|
|
73
|
+
readonly entityName: string;
|
|
74
|
+
readonly tableName: string;
|
|
75
|
+
readonly featureName: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CF_FIRST: HostSpec = {
|
|
79
|
+
entityName: "cf-first-prop",
|
|
80
|
+
tableName: "read_dsgvo_cf_first",
|
|
81
|
+
featureName: "dsgvo-cf-first",
|
|
82
|
+
};
|
|
83
|
+
const HOST_FIRST: HostSpec = {
|
|
84
|
+
entityName: "host-first-prop",
|
|
85
|
+
tableName: "read_dsgvo_host_first",
|
|
86
|
+
featureName: "dsgvo-host-first",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function makeEntity(tableName: string) {
|
|
90
|
+
return createEntity({
|
|
91
|
+
table: tableName,
|
|
92
|
+
fields: {
|
|
93
|
+
name: createTextField({ required: true }),
|
|
94
|
+
customFields: customFieldsField(),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cfFirstEntity = makeEntity(CF_FIRST.tableName);
|
|
100
|
+
const hostFirstEntity = makeEntity(HOST_FIRST.tableName);
|
|
101
|
+
const cfFirstTable = buildEntityTable(CF_FIRST.entityName, cfFirstEntity);
|
|
102
|
+
const hostFirstTable = buildEntityTable(HOST_FIRST.entityName, hostFirstEntity);
|
|
103
|
+
|
|
104
|
+
// Registration order is the whole point: cfFirst registers the strip BEFORE the
|
|
105
|
+
// owner-nulling host hook; hostFirst registers the host hook FIRST (the order
|
|
106
|
+
// that exposed the bug). The order-sentinel must make both correct.
|
|
107
|
+
const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
|
|
108
|
+
r.entity(CF_FIRST.entityName, cfFirstEntity);
|
|
109
|
+
r.requires("custom-fields");
|
|
110
|
+
wireCustomFieldsUserDataRightsFor(r, {
|
|
111
|
+
entityName: CF_FIRST.entityName,
|
|
112
|
+
entityTable: cfFirstTable,
|
|
113
|
+
userIdColumn: "inserted_by_id",
|
|
114
|
+
});
|
|
115
|
+
r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
|
|
116
|
+
export: async () => null,
|
|
117
|
+
delete: makeHostDeleteHook(CF_FIRST.tableName),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
|
|
122
|
+
r.entity(HOST_FIRST.entityName, hostFirstEntity);
|
|
123
|
+
r.requires("custom-fields");
|
|
124
|
+
r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
|
|
125
|
+
export: async () => null,
|
|
126
|
+
delete: makeHostDeleteHook(HOST_FIRST.tableName),
|
|
127
|
+
});
|
|
128
|
+
wireCustomFieldsUserDataRightsFor(r, {
|
|
129
|
+
entityName: HOST_FIRST.entityName,
|
|
130
|
+
entityTable: hostFirstTable,
|
|
131
|
+
userIdColumn: "inserted_by_id",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: TENANT });
|
|
136
|
+
const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000a1";
|
|
137
|
+
|
|
138
|
+
let stack: TestStack;
|
|
139
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
|
|
140
|
+
const seed = (db: unknown) => createForgetSeeders(db as any, { write: async () => {} });
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
stack = await setupTestStack({
|
|
144
|
+
features: [
|
|
145
|
+
createUserFeature(),
|
|
146
|
+
createSessionsFeature(),
|
|
147
|
+
createDataRetentionFeature(),
|
|
148
|
+
createComplianceProfilesFeature(),
|
|
149
|
+
createCustomFieldsFeature(),
|
|
150
|
+
createUserDataRightsFeature(),
|
|
151
|
+
cfFirstFeature,
|
|
152
|
+
hostFirstFeature,
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
156
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
157
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
158
|
+
await unsafeCreateEntityTable(stack.db, cfFirstEntity);
|
|
159
|
+
await unsafeCreateEntityTable(stack.db, hostFirstEntity);
|
|
160
|
+
await createEventsTable(stack.db);
|
|
161
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(async () => {
|
|
165
|
+
await stack.cleanup();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
async function defineSensitiveField(entityName: string, fieldKey: string, sensitive: boolean) {
|
|
169
|
+
await stack.http.writeOk(
|
|
170
|
+
"custom-fields:write:define-tenant-field",
|
|
171
|
+
{
|
|
172
|
+
entityName,
|
|
173
|
+
fieldKey,
|
|
174
|
+
serializedField: { type: "text", sensitive },
|
|
175
|
+
required: false,
|
|
176
|
+
searchable: false,
|
|
177
|
+
displayOrder: 0,
|
|
178
|
+
},
|
|
179
|
+
admin,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function seedAnonymizeOverride(entityName: string) {
|
|
184
|
+
await insertOne(stack.db, tenantRetentionOverrideTable, {
|
|
185
|
+
entityName,
|
|
186
|
+
config: JSON.stringify({ keepFor: "0d", strategy: "anonymize" }),
|
|
187
|
+
reason: "test: force anonymize strategy",
|
|
188
|
+
tenantId: TENANT,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function seedRow(spec: HostSpec, rowId: string) {
|
|
193
|
+
// Inline jsonb literal — binding the JSON as a `$n::jsonb` param double-encodes
|
|
194
|
+
// it into a jsonb *string* scalar (jsonb_typeof='string'), which the strip's
|
|
195
|
+
// object-guard skips. A literal stores a real object, matching what the
|
|
196
|
+
// set-custom-field projection writes in production.
|
|
197
|
+
await asRawClient(stack.db).unsafe(
|
|
198
|
+
`INSERT INTO ${spec.tableName} (id, tenant_id, name, inserted_by_id, custom_fields)
|
|
199
|
+
VALUES ($1, $2, $3, $4, '{"ssn":"123-45-6789","safe":"keep-me"}'::jsonb)`,
|
|
200
|
+
[rowId, TENANT, `Row for ${spec.entityName}`, FORGET_USER],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readRow(
|
|
205
|
+
spec: HostSpec,
|
|
206
|
+
rowId: string,
|
|
207
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
208
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
209
|
+
`SELECT id, inserted_by_id, custom_fields FROM ${spec.tableName} WHERE id = $1`,
|
|
210
|
+
[rowId],
|
|
211
|
+
);
|
|
212
|
+
return (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
beforeEach(async () => {
|
|
216
|
+
await resetEventStore(stack);
|
|
217
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${CF_FIRST.tableName}`);
|
|
218
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${HOST_FIRST.tableName}`);
|
|
219
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
220
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_retention_overrides`);
|
|
221
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_tenant_memberships`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("forget hook-ordering :: redaction runs before owner-nulling regardless of registration order", () => {
|
|
225
|
+
test("both registration orders strip the sensitive jsonb key and null the owner column", async () => {
|
|
226
|
+
// Field defs (sensitive ssn, non-sensitive safe) for both entities.
|
|
227
|
+
for (const spec of [CF_FIRST, HOST_FIRST]) {
|
|
228
|
+
await defineSensitiveField(spec.entityName, "ssn", true);
|
|
229
|
+
await defineSensitiveField(spec.entityName, "safe", false);
|
|
230
|
+
await seedAnonymizeOverride(spec.entityName);
|
|
231
|
+
}
|
|
232
|
+
// Project the field definitions. Assert the pass had no failures: a silent
|
|
233
|
+
// projection failure would leave read_custom_field_definitions empty, make
|
|
234
|
+
// loadSensitiveFieldKeys return [], and turn the strip into a no-op —
|
|
235
|
+
// verifying the test exercises the strip for the right reason.
|
|
236
|
+
const pass = await stack.eventDispatcher?.runOnce();
|
|
237
|
+
expect(pass?.failed ?? 0).toBe(0);
|
|
238
|
+
|
|
239
|
+
const cfRowId = "dddddddd-dddd-4ddd-8ddd-000000000001";
|
|
240
|
+
const hostRowId = "dddddddd-dddd-4ddd-8ddd-000000000002";
|
|
241
|
+
await seedRow(CF_FIRST, cfRowId);
|
|
242
|
+
await seedRow(HOST_FIRST, hostRowId);
|
|
243
|
+
|
|
244
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
245
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT);
|
|
246
|
+
|
|
247
|
+
const result = await runForgetCleanup({
|
|
248
|
+
db: stack.db,
|
|
249
|
+
registry: stack.registry,
|
|
250
|
+
now: nowInstant(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(result.errors).toHaveLength(0);
|
|
254
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
255
|
+
|
|
256
|
+
for (const [spec, rowId] of [
|
|
257
|
+
[CF_FIRST, cfRowId],
|
|
258
|
+
[HOST_FIRST, hostRowId],
|
|
259
|
+
] as const) {
|
|
260
|
+
const row = await readRow(spec, rowId);
|
|
261
|
+
// Owner column nulled by the host anonymize hook.
|
|
262
|
+
expect(row?.["inserted_by_id"]).toBeNull();
|
|
263
|
+
const cf = row?.["custom_fields"] as Record<string, unknown>;
|
|
264
|
+
// Sensitive key stripped (the strip ran while the owner column still
|
|
265
|
+
// pointed to the user) — this is the assertion that fails for the
|
|
266
|
+
// host-first entity without the order-sentinel fix.
|
|
267
|
+
expect(cf).not.toHaveProperty("ssn");
|
|
268
|
+
// Non-sensitive key preserved.
|
|
269
|
+
expect(cf["safe"]).toBe("keep-me");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Shared seeders for the forget-cleanup integration tests
|
|
2
|
+
// (file-binary-forget-cleanup + file-binary-forget-failure). Both drive
|
|
3
|
+
// runForgetCleanup against the same fileRef + membership shape — keeping the
|
|
4
|
+
// DDL and seed logic in one place so a file_refs/user-schema change updates
|
|
5
|
+
// both at once instead of drifting.
|
|
6
|
+
|
|
7
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
10
|
+
import { USER_STATUS, userTable } from "../../user";
|
|
11
|
+
|
|
12
|
+
export const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
13
|
+
|
|
14
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
15
|
+
export const nowInstant = (): Instant => getTemporal().Now.instant();
|
|
16
|
+
export const pastInstant = (): Instant =>
|
|
17
|
+
getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
18
|
+
|
|
19
|
+
export const READ_TENANT_MEMBERSHIPS_DDL = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
21
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
+
tenant_id UUID NOT NULL,
|
|
23
|
+
user_id TEXT NOT NULL,
|
|
24
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
26
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
27
|
+
inserted_by_id TEXT,
|
|
28
|
+
modified_by_id TEXT,
|
|
29
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
30
|
+
deleted_at TIMESTAMPTZ,
|
|
31
|
+
deleted_by_id TEXT,
|
|
32
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
33
|
+
UNIQUE(user_id, tenant_id)
|
|
34
|
+
)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
interface FileWriter {
|
|
38
|
+
write(key: string, bytes: Uint8Array, mimeType: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ForgetSeeders {
|
|
42
|
+
seedForgetUser(id: string): Promise<void>;
|
|
43
|
+
seedMembership(userId: string, tenantId: string): Promise<void>;
|
|
44
|
+
seedFile(id: string, tenantId: string, insertedById: string): Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `writer` is taken separately from the feature's storageProvider so the
|
|
48
|
+
// failure test can seed binaries through the real backing store while the
|
|
49
|
+
// feature runs against a delete-failing wrapper.
|
|
50
|
+
export function createForgetSeeders(db: DbConnection, writer: FileWriter): ForgetSeeders {
|
|
51
|
+
return {
|
|
52
|
+
async seedForgetUser(id) {
|
|
53
|
+
await insertOne(db, userTable, {
|
|
54
|
+
id,
|
|
55
|
+
tenantId: TENANT_SYSTEM,
|
|
56
|
+
email: `user-${id}@example.com`,
|
|
57
|
+
passwordHash: "hashed",
|
|
58
|
+
displayName: `User ${id}`,
|
|
59
|
+
locale: "de",
|
|
60
|
+
emailVerified: true,
|
|
61
|
+
roles: '["Member"]',
|
|
62
|
+
status: USER_STATUS.DeletionRequested,
|
|
63
|
+
gracePeriodEnd: pastInstant(),
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async seedMembership(userId, tenantId) {
|
|
68
|
+
await asRawClient(db).unsafe(
|
|
69
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
70
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
71
|
+
[tenantId, userId],
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async seedFile(id, tenantId, insertedById) {
|
|
76
|
+
const storageKey = `storage/${id}`;
|
|
77
|
+
await writer.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
|
|
78
|
+
await asRawClient(db).unsafe(
|
|
79
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
80
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
|
|
81
|
+
[id, tenantId, storageKey, `${id}.pdf`, insertedById],
|
|
82
|
+
);
|
|
83
|
+
return storageKey;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { Temporal } from "temporal-polyfill";
|
|
4
5
|
import { exportJobsTable } from "../../schema/export-job";
|
|
5
6
|
|
|
6
7
|
export type ExportJobCleanupCandidate = {
|
|
@@ -104,8 +104,16 @@ export interface RunForgetCleanupResult {
|
|
|
104
104
|
interface HookEntry {
|
|
105
105
|
readonly entityName: string;
|
|
106
106
|
readonly deleteHook: UserDataDeleteHook;
|
|
107
|
+
/** Lower runs first. Owner-column-preserving redaction declares a negative
|
|
108
|
+
* order so it precedes owner-nulling hooks on the same entity (see sort below). */
|
|
109
|
+
readonly order: number;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
|
|
113
|
+
// owner column it doesn't own must register a lower order so it runs BEFORE any
|
|
114
|
+
// hook that nulls that column. See custom-fields wire-user-data-rights.ts.
|
|
115
|
+
const HOOK_ORDER_DEFAULT = 0;
|
|
116
|
+
|
|
109
117
|
export async function runForgetCleanup(
|
|
110
118
|
args: RunForgetCleanupArgs,
|
|
111
119
|
): Promise<RunForgetCleanupResult> {
|
|
@@ -127,10 +135,18 @@ export async function runForgetCleanup(
|
|
|
127
135
|
const usages = registry.getExtensionUsages(EXT_USER_DATA);
|
|
128
136
|
const hookEntries: HookEntry[] = usages
|
|
129
137
|
.map((u): HookEntry | null => {
|
|
130
|
-
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook }; // @cast-boundary engine-payload
|
|
131
|
-
|
|
138
|
+
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
|
|
139
|
+
if (!opts.delete) return null;
|
|
140
|
+
const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
|
|
141
|
+
return { entityName: u.entityName, deleteHook: opts.delete, order };
|
|
132
142
|
})
|
|
133
|
-
.filter((x): x is HookEntry => x !== null)
|
|
143
|
+
.filter((x): x is HookEntry => x !== null)
|
|
144
|
+
// Order ascending. Array.sort is ES2019-stable, so equal orders keep
|
|
145
|
+
// registration order; correctness here needs only distinct orders, not
|
|
146
|
+
// stability. Guarantees owner-preserving redaction (negative order) runs
|
|
147
|
+
// before owner-nulling hooks on the same entity, independent of feature
|
|
148
|
+
// registration order.
|
|
149
|
+
.sort((a, b) => a.order - b.order);
|
|
134
150
|
|
|
135
151
|
const errors: ForgetCleanupError[] = [];
|
|
136
152
|
const processedUserIds: string[] = [];
|
|
@@ -233,46 +249,40 @@ async function processUser(args: {
|
|
|
233
249
|
memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
|
|
234
250
|
|
|
235
251
|
// Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
|
|
236
|
-
// nur dieser User zurueck, andere User bleiben commit-fest.
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
|
|
242
|
-
// die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
|
|
243
|
-
// PgTransaction haben unterschiedliche Generics). Cast macht das
|
|
244
|
-
// Strukturelle explizit, kein Hack.
|
|
252
|
+
// nur dieser User zurueck, andere User bleiben commit-fest. Die Sub-Tx
|
|
253
|
+
// nestet korrekt: eine Top-Level-Connection oeffnet sie via `.begin`
|
|
254
|
+
// (BEGIN), eine TransactionSql — der Fall im Dispatcher, wo jeder
|
|
255
|
+
// writeHandler bereits IN der Outer-Tx laeuft — via `.savepoint`
|
|
256
|
+
// (SAVEPOINT). Siehe runInSubTransaction.
|
|
245
257
|
let txSucceeded = false;
|
|
246
258
|
let currentTenantId: TenantId | null = null;
|
|
247
259
|
let currentEntityName: string | null = null;
|
|
248
260
|
try {
|
|
249
|
-
await (db
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
261
|
+
await runInSubTransaction(db, async (tx) => {
|
|
262
|
+
for (const tenantId of tenantList) {
|
|
263
|
+
currentTenantId = tenantId;
|
|
264
|
+
for (const entry of hookEntries) {
|
|
265
|
+
currentEntityName = entry.entityName;
|
|
266
|
+
const policy = await resolveRetentionPolicyForTenant({
|
|
267
|
+
db: tx,
|
|
268
|
+
registry,
|
|
269
|
+
tenantId,
|
|
270
|
+
entityName: entry.entityName,
|
|
271
|
+
});
|
|
272
|
+
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
262
273
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
274
|
+
hookCallsAttempted++;
|
|
275
|
+
await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
|
|
266
276
|
}
|
|
277
|
+
}
|
|
267
278
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
);
|
|
279
|
+
// Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
|
|
280
|
+
// geworfen hat, kommen wir hier nicht an — die Tx rollback'd
|
|
281
|
+
// alles, der User bleibt im DeletionRequested-Status, naechster
|
|
282
|
+
// Run retried.
|
|
283
|
+
await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
|
|
284
|
+
txSucceeded = true;
|
|
285
|
+
});
|
|
276
286
|
} catch (e) {
|
|
277
287
|
// currentTenantId/currentEntityName tracken den Failing-Hook —
|
|
278
288
|
// Operator sieht "Hook fileRef in Tenant A failed for user X" statt
|
|
@@ -294,6 +304,37 @@ async function processUser(args: {
|
|
|
294
304
|
};
|
|
295
305
|
}
|
|
296
306
|
|
|
307
|
+
// Per-user sub-transaction, nesting-aware across both db shapes:
|
|
308
|
+
// - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
|
|
309
|
+
// - TransactionSql (inside the dispatcher's outer tx, where every
|
|
310
|
+
// writeHandler already runs) → `.savepoint` (SAVEPOINT)
|
|
311
|
+
// A TransactionSql has no `.begin`, so the previous unconditional `.begin`
|
|
312
|
+
// threw "is not a function" on every user when invoked through the dispatcher
|
|
313
|
+
// (the cron path) → zero deletions in production, while direct-connection tests
|
|
314
|
+
// stayed green. Selecting the available method makes the sub-tx work in both
|
|
315
|
+
// contexts; on throw the savepoint rolls back just this user (others survive).
|
|
316
|
+
async function runInSubTransaction(
|
|
317
|
+
db: DbRunner,
|
|
318
|
+
fn: (tx: DbRunner) => Promise<void>,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
// `db` is already the raw runner (the handler passes ctx.db.raw, the tests a
|
|
321
|
+
// top-level connection) — cast to read the transaction surface directly,
|
|
322
|
+
// without asRawClient (a test-only escape hatch). A top-level connection
|
|
323
|
+
// exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
|
|
324
|
+
// exclusive, so prefer whichever is present.
|
|
325
|
+
const runner = db as {
|
|
326
|
+
begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
327
|
+
savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
328
|
+
};
|
|
329
|
+
const open = runner.begin ?? runner.savepoint;
|
|
330
|
+
if (!open) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await open.call(runner, fn);
|
|
336
|
+
}
|
|
337
|
+
|
|
297
338
|
// Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
|
|
298
339
|
// Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
|
|
299
340
|
// tenant-agnostische Hooks (z.B. user) operieren auf der globalen
|
|
@@ -11,15 +11,21 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
|
|
|
11
11
|
//
|
|
12
12
|
// Delete-Hook entfernt FileRef-Zeile via factory
|
|
13
13
|
// `createFileRefDeleteHook(storageProvider)`:
|
|
14
|
-
// "delete": storageProvider.delete() pro File
|
|
14
|
+
// "delete": storageProvider.delete() pro File + Row hard-delete
|
|
15
15
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
16
16
|
// koennen weiter zeigen; Personenbezug raus)
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
18
|
+
// Delete-Pfad ist FAIL-CLOSED, sobald ein Provider gewired ist: schlaegt ein
|
|
19
|
+
// binary-delete fehl, wirft der Hook
|
|
20
|
+
// NACH dem Loop — die per-User-Sub-Tx von runForgetCleanup rollt zurueck, der
|
|
21
|
+
// User bleibt DeletionRequested, der naechste Run retried (storageProvider.delete
|
|
22
|
+
// ist idempotent, schon-geloeschte Keys sind no-op). Den Fehler zu schlucken und
|
|
23
|
+
// die Row trotzdem hard-zu-loeschen wuerde Art.-17-Erasure als "done" markieren
|
|
24
|
+
// waehrend die Bytes auf Disk bleiben — eine falsche Compliance-Aussage. Das
|
|
25
|
+
// "KEIN globaler Rollback" der Sprint-2-Atomicity-Decision bleibt gewahrt: nur
|
|
26
|
+
// DIESE User-Sub-Tx rollt zurueck (= der Retry-Mechanismus), andere User des
|
|
27
|
+
// Laufs committen. Der anonymize-Pfad behaelt Row+binary bewusst, hat also
|
|
28
|
+
// nichts zu schlucken.
|
|
23
29
|
//
|
|
24
30
|
// `storageProvider` ist optional. App-Author wired es beim
|
|
25
31
|
// Feature-Mount rein (`createUserDataRightsDefaultsFeature({
|
|
@@ -92,6 +98,7 @@ export function createFileRefDeleteHook(
|
|
|
92
98
|
tenantId: ctx.tenantId,
|
|
93
99
|
insertedById: ctx.userId,
|
|
94
100
|
});
|
|
101
|
+
const failedKeys: string[] = [];
|
|
95
102
|
for (const row of rows) {
|
|
96
103
|
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
97
104
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
@@ -102,8 +109,16 @@ export function createFileRefDeleteHook(
|
|
|
102
109
|
console.warn(
|
|
103
110
|
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
104
111
|
);
|
|
112
|
+
failedKeys.push(key);
|
|
105
113
|
}
|
|
106
114
|
}
|
|
115
|
+
// Fail-closed: abort before the row hard-delete so the sub-tx rolls back
|
|
116
|
+
// and the next forget run retries (delete is idempotent → converges).
|
|
117
|
+
if (failedKeys.length > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
107
122
|
} else if (!missingStorageWarned) {
|
|
108
123
|
missingStorageWarned = true;
|
|
109
124
|
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|