@cosmicdrift/kumiko-bundled-features 0.23.1 → 0.24.1
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/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/db/queries/projection.ts +8 -4
- package/src/custom-fields/handlers/clear-custom-field.write.ts +3 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +5 -4
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/wire-for-entity.ts +8 -1
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +178 -0
- package/src/user-data-rights-defaults/feature.ts +16 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +66 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
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>",
|
|
@@ -236,6 +236,43 @@ describe("scenario 5: change-password success", () => {
|
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
describe("scenario 5b: change-password when the aggregate stream tenant != session tenant (sysadmin pattern)", () => {
|
|
240
|
+
test("user whose stream lives in a non-session tenant still changes password", async () => {
|
|
241
|
+
// Sysadmin pattern: created via systemAdmin (stream = systemAdmin.tenantId),
|
|
242
|
+
// only membership on a different tenant → the session/membership tenant
|
|
243
|
+
// diverges from where the aggregate actually lives. change-password writes
|
|
244
|
+
// as the session tenant; without recovering the real stream tenant it
|
|
245
|
+
// version_conflicts against an empty stream and the operator is locked out
|
|
246
|
+
// of rotating their own password.
|
|
247
|
+
const membershipTenant = testTenantId(2);
|
|
248
|
+
const seed = await seedLoginUser({
|
|
249
|
+
email: "sysadmin-cp@example.com",
|
|
250
|
+
password: "old-sysadmin-pw",
|
|
251
|
+
tenantId: membershipTenant,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
255
|
+
`SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
|
|
256
|
+
[seed.id, "user"],
|
|
257
|
+
)) as ReadonlyArray<{ t: string }>;
|
|
258
|
+
expect(streamRows[0]?.t).toBe(systemAdmin.tenantId);
|
|
259
|
+
expect(streamRows[0]?.t).not.toBe(membershipTenant);
|
|
260
|
+
|
|
261
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
262
|
+
await stack.http.writeOk(
|
|
263
|
+
AuthHandlers.changePassword,
|
|
264
|
+
{ oldPassword: "old-sysadmin-pw", newPassword: "new-sysadmin-pw-long" },
|
|
265
|
+
signedIn,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const newRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
269
|
+
email: "sysadmin-cp@example.com",
|
|
270
|
+
password: "new-sysadmin-pw-long",
|
|
271
|
+
});
|
|
272
|
+
expect(newRes.status).toBe(200);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
239
276
|
// --- Scenario 6: logout is reachable for authenticated users ---
|
|
240
277
|
|
|
241
278
|
describe("scenario 6: logout", () => {
|
|
@@ -322,3 +322,35 @@ describe("login with strict email-verification", () => {
|
|
|
322
322
|
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
323
323
|
});
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
describe("verify-email — aggregate stream in a non-membership tenant (sysadmin pattern)", () => {
|
|
327
|
+
test("user whose aggregate stream lives in a tenant they are NOT a member of still verifies", async () => {
|
|
328
|
+
// Prod sysadmin repro: the user aggregate is created via `systemAdmin`,
|
|
329
|
+
// so its event stream lives in systemAdmin.tenantId (…0001), while the
|
|
330
|
+
// user's ONLY membership is on a different tenant (…0002). resolveStream-
|
|
331
|
+
// Tenants must discover the real stream tenant from the event log — if it
|
|
332
|
+
// only tried membership tenants, every write would target …0002, get a
|
|
333
|
+
// version_conflict, collapse to all_conflicts → invalid_verification_token.
|
|
334
|
+
const membershipTenant = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
335
|
+
const seed = await seedUser({
|
|
336
|
+
email: "sysadmin-pattern@example.com",
|
|
337
|
+
password: "pw-sysadmin-pat-1234",
|
|
338
|
+
tenantId: membershipTenant,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
342
|
+
`SELECT "tenant_id", "aggregate_type" FROM "kumiko_events" WHERE "aggregate_id" = $1 ORDER BY "version" LIMIT 1`,
|
|
343
|
+
[seed.id],
|
|
344
|
+
)) as ReadonlyArray<{ tenant_id: string; aggregate_type: string }>;
|
|
345
|
+
expect(streamRows[0]?.aggregate_type).toBe("user");
|
|
346
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
347
|
+
expect(streamRows[0]?.tenant_id).not.toBe(membershipTenant);
|
|
348
|
+
|
|
349
|
+
const { token } = signVerificationToken(seed.id, 60, verifySecret);
|
|
350
|
+
const res = await post("/api/auth/verify-email", { token });
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
|
|
353
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
354
|
+
expect(row?.["emailVerified"]).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -303,6 +303,37 @@ describe("POST /auth/reset-password", () => {
|
|
|
303
303
|
const body = await second.json();
|
|
304
304
|
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
test("user whose aggregate stream lives in a tenant they are NOT a member of can still reset", async () => {
|
|
308
|
+
// Mirror of the verify-email sysadmin repro: aggregate stream in …0001
|
|
309
|
+
// (created via systemAdmin), only membership on …0002. The reset must
|
|
310
|
+
// target the real stream tenant, not the membership tenant.
|
|
311
|
+
const membershipTenant = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
312
|
+
const seed = await seedUser({
|
|
313
|
+
email: "sysadmin-reset@example.com",
|
|
314
|
+
password: "pw-old-sysadmin-1234",
|
|
315
|
+
tenantId: membershipTenant,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
319
|
+
`SELECT "tenant_id", "aggregate_type" FROM "kumiko_events" WHERE "aggregate_id" = $1 ORDER BY "version" LIMIT 1`,
|
|
320
|
+
[seed.id],
|
|
321
|
+
)) as ReadonlyArray<{ tenant_id: string; aggregate_type: string }>;
|
|
322
|
+
expect(streamRows[0]?.aggregate_type).toBe("user");
|
|
323
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
324
|
+
expect(streamRows[0]?.tenant_id).not.toBe(membershipTenant);
|
|
325
|
+
|
|
326
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
327
|
+
const res = await post("/api/auth/reset-password", {
|
|
328
|
+
token,
|
|
329
|
+
newPassword: "pw-new-sysadmin-1234",
|
|
330
|
+
});
|
|
331
|
+
expect(res.status).toBe(200);
|
|
332
|
+
|
|
333
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
334
|
+
const valid = await verifyPassword(row?.["passwordHash"] as string, "pw-new-sysadmin-1234");
|
|
335
|
+
expect(valid).toBe(true);
|
|
336
|
+
});
|
|
306
337
|
});
|
|
307
338
|
|
|
308
339
|
// --- session auto-revoke (H.3 cross-feature hook) -------------------------
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import { UserHandlers, UserQueries } from "../../user";
|
|
5
|
+
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
5
6
|
import { AuthErrors } from "../constants";
|
|
6
7
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
7
8
|
|
|
@@ -43,10 +44,19 @@ export const changePasswordWrite = defineWriteHandler({
|
|
|
43
44
|
|
|
44
45
|
const newHash = await hashPassword(event.payload.newPassword);
|
|
45
46
|
|
|
47
|
+
// The user aggregate is r.systemScope(): its event stream can live in a
|
|
48
|
+
// tenant that isn't the session tenant (a platform operator's stream sits
|
|
49
|
+
// in the seed executor's tenant, not their membership). Write against the
|
|
50
|
+
// real stream tenant so optimistic locking targets the right stream instead
|
|
51
|
+
// of version_conflict-ing against an empty one. Falls back to the session
|
|
52
|
+
// tenant when the lookup finds nothing (fresh/unknown stream).
|
|
53
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
54
|
+
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
55
|
+
|
|
46
56
|
// Apply via user feature's update handler — writeAs(system) satisfies
|
|
47
57
|
// the privileged-only write rule on passwordHash. Pass the current version
|
|
48
58
|
// through so optimistic locking still applies end-to-end.
|
|
49
|
-
const writeRes = await ctx.writeAs(
|
|
59
|
+
const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
|
|
50
60
|
id: me.id,
|
|
51
61
|
version: me.version,
|
|
52
62
|
changes: { passwordHash: newHash },
|
|
@@ -29,8 +29,9 @@ import {
|
|
|
29
29
|
type WriteFailure,
|
|
30
30
|
writeFailure,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
32
33
|
import type Redis from "ioredis";
|
|
33
|
-
import { UserHandlers, UserQueries } from "../../user";
|
|
34
|
+
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
34
35
|
import type { AuthUserRow } from "../auth-user-row";
|
|
35
36
|
import { parseAuthUserRow } from "../auth-user-row";
|
|
36
37
|
import { orderTenantsByPreference } from "../stream-tenant";
|
|
@@ -153,7 +154,21 @@ async function resolveStreamTenants(
|
|
|
153
154
|
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
154
155
|
userId: me.id,
|
|
155
156
|
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
156
|
-
|
|
157
|
+
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
158
|
+
if (ordered.length === 0) return [];
|
|
159
|
+
|
|
160
|
+
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
161
|
+
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
162
|
+
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
163
|
+
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
164
|
+
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
165
|
+
// tenant from the event log and try it first; memberships stay as fallback,
|
|
166
|
+
// and an empty/unknown lookup degrades to the prior membership-only behaviour.
|
|
167
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
168
|
+
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
169
|
+
return [streamTenant, ...ordered];
|
|
170
|
+
}
|
|
171
|
+
return ordered;
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
// Discriminated result for the write-across-tenants loop.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { parseComplianceProfileOverride } from "../_internal/parse-override";
|
|
3
|
+
|
|
4
|
+
describe("parseComplianceProfileOverride", () => {
|
|
5
|
+
test("empty / whitespace / null → undefined, no warning", () => {
|
|
6
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
7
|
+
try {
|
|
8
|
+
expect(parseComplianceProfileOverride(null, "t1", "seed")).toBeUndefined();
|
|
9
|
+
expect(parseComplianceProfileOverride("", "t1", "seed")).toBeUndefined();
|
|
10
|
+
expect(parseComplianceProfileOverride(" ", "t1", "seed")).toBeUndefined();
|
|
11
|
+
expect(warn).not.toHaveBeenCalled();
|
|
12
|
+
} finally {
|
|
13
|
+
warn.mockRestore();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("valid JSON object is returned verbatim", () => {
|
|
18
|
+
expect(parseComplianceProfileOverride('{"region":"eu"}', "t1", "seed")).toEqual({
|
|
19
|
+
region: "eu",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("literal JSON null → undefined (no override), no warning", () => {
|
|
24
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
25
|
+
try {
|
|
26
|
+
expect(parseComplianceProfileOverride("null", "t1", "seed")).toBeUndefined();
|
|
27
|
+
expect(warn).not.toHaveBeenCalled();
|
|
28
|
+
} finally {
|
|
29
|
+
warn.mockRestore();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("corrupt JSON warns WITH the parser reason and returns undefined (not a silent swallow)", () => {
|
|
34
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
35
|
+
try {
|
|
36
|
+
const result = parseComplianceProfileOverride(
|
|
37
|
+
"{not valid json",
|
|
38
|
+
"tenant-9",
|
|
39
|
+
"resolve-for-tenant",
|
|
40
|
+
);
|
|
41
|
+
expect(result).toBeUndefined();
|
|
42
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
43
|
+
const msg = String(warn.mock.calls[0]?.[0]);
|
|
44
|
+
expect(msg).toContain("tenant-9");
|
|
45
|
+
expect(msg).toContain("resolve-for-tenant");
|
|
46
|
+
// The point of the fix: the parser's own failure reason is preserved,
|
|
47
|
+
// not flattened to a generic "is not valid JSON".
|
|
48
|
+
expect(msg).toContain("Reason:");
|
|
49
|
+
} finally {
|
|
50
|
+
warn.mockRestore();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComplianceProfileOverride } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
|
-
import {
|
|
2
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
3
|
|
|
4
4
|
export function parseComplianceProfileOverride(
|
|
5
5
|
raw: string | null,
|
|
@@ -7,13 +7,14 @@ export function parseComplianceProfileOverride(
|
|
|
7
7
|
callerLabel: string,
|
|
8
8
|
): ComplianceProfileOverride | undefined {
|
|
9
9
|
if (!raw || raw.trim() === "") return undefined;
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
let parsed: ComplianceProfileOverride | null;
|
|
11
|
+
try {
|
|
12
|
+
parsed = parseJsonOrThrow<ComplianceProfileOverride | null>(raw, "compliance override");
|
|
13
|
+
} catch (err) {
|
|
14
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
12
15
|
// biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
|
|
13
|
-
console.warn(
|
|
14
|
-
`[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring.`,
|
|
15
|
-
);
|
|
16
|
+
console.warn(`[${callerLabel}] tenant ${tenantId}: stored override ignored. Reason: ${reason}`);
|
|
16
17
|
return undefined;
|
|
17
18
|
}
|
|
18
|
-
return parsed;
|
|
19
|
+
return parsed ?? undefined;
|
|
19
20
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Regression: a custom-field set/clear must only touch the calling tenant's own
|
|
2
|
+
// row. aggregateId is a globally-unique row UUID, so without a tenant_id filter
|
|
3
|
+
// on the projection UPDATE, tenant A could overwrite or clear tenant B's
|
|
4
|
+
// customFields just by passing B's known row UUID as entityId. The set/clear
|
|
5
|
+
// projection writes now scope to the event's own tenant (same guard the
|
|
6
|
+
// fieldDefinition-delete path already uses).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
createEntity,
|
|
13
|
+
createEntityExecutor,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineEntityListHandler,
|
|
16
|
+
defineFeature,
|
|
17
|
+
type SessionUser,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
20
|
+
import {
|
|
21
|
+
createTestUser,
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
testTenantId,
|
|
25
|
+
testUserId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
30
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
31
|
+
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
32
|
+
|
|
33
|
+
const propertyEntity = createEntity({
|
|
34
|
+
table: "read_xt_set_properties",
|
|
35
|
+
fields: {
|
|
36
|
+
name: createTextField({ required: true }),
|
|
37
|
+
customFields: customFieldsField(),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const propertyTable = buildEntityTable("property", propertyEntity);
|
|
41
|
+
|
|
42
|
+
const propertyFeature = defineFeature("property-xt-set", (r) => {
|
|
43
|
+
r.entity("property", propertyEntity);
|
|
44
|
+
r.requires("custom-fields");
|
|
45
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
46
|
+
|
|
47
|
+
const { executor: propertyExecutor } = createEntityExecutor("property", propertyEntity);
|
|
48
|
+
r.writeHandler({
|
|
49
|
+
name: "property:create",
|
|
50
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
51
|
+
access: { roles: ["TenantAdmin"] },
|
|
52
|
+
handler: async (event, ctx) => {
|
|
53
|
+
const payload = event.payload as { id: string; name: string };
|
|
54
|
+
return propertyExecutor.create(
|
|
55
|
+
{ id: payload.id, name: payload.name, customFields: {} },
|
|
56
|
+
event.user,
|
|
57
|
+
ctx.db,
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
r.queryHandler(
|
|
63
|
+
defineEntityListHandler("property", propertyEntity, { access: { roles: ["TenantAdmin"] } }),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const customFieldsFeature = createCustomFieldsFeature();
|
|
68
|
+
|
|
69
|
+
let stack: TestStack;
|
|
70
|
+
|
|
71
|
+
const adminA = createTestUser({
|
|
72
|
+
id: testUserId(1),
|
|
73
|
+
tenantId: testTenantId(1),
|
|
74
|
+
roles: ["TenantAdmin"],
|
|
75
|
+
});
|
|
76
|
+
const adminB = createTestUser({
|
|
77
|
+
id: testUserId(10),
|
|
78
|
+
tenantId: testTenantId(2),
|
|
79
|
+
roles: ["TenantAdmin"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const PROP_A = "aaaaaaaa-aaaa-4000-8000-000000000001";
|
|
83
|
+
const PROP_B = "bbbbbbbb-bbbb-4000-8000-000000000002";
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
stack = await setupTestStack({ features: [customFieldsFeature, propertyFeature] });
|
|
87
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
89
|
+
await createEventsTable(stack.db);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
await stack.cleanup();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function defineField(user: SessionUser, fieldKey: string) {
|
|
97
|
+
return stack.http.writeOk(
|
|
98
|
+
"custom-fields:write:define-tenant-field",
|
|
99
|
+
{
|
|
100
|
+
entityName: "property",
|
|
101
|
+
fieldKey,
|
|
102
|
+
serializedField: { type: "text" },
|
|
103
|
+
required: false,
|
|
104
|
+
searchable: false,
|
|
105
|
+
displayOrder: 0,
|
|
106
|
+
},
|
|
107
|
+
user,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function setField(user: SessionUser, entityId: string, fieldKey: string, value: unknown) {
|
|
112
|
+
return stack.http.writeOk(
|
|
113
|
+
"custom-fields:write:set-custom-field",
|
|
114
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
115
|
+
user,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function clearField(user: SessionUser, entityId: string, fieldKey: string) {
|
|
120
|
+
return stack.http.writeOk(
|
|
121
|
+
"custom-fields:write:clear-custom-field",
|
|
122
|
+
{ entityName: "property", entityId, fieldKey },
|
|
123
|
+
user,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function createProperty(user: SessionUser, id: string, name: string) {
|
|
128
|
+
return stack.http.writeOk("property-xt-set:write:property:create", { id, name }, user);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function priorityOf(user: SessionUser, id: string): Promise<unknown> {
|
|
132
|
+
const { rows } = (await stack.http.queryOk("property-xt-set:query:property:list", {}, user)) as {
|
|
133
|
+
rows: Array<Record<string, unknown>>;
|
|
134
|
+
};
|
|
135
|
+
return rows.find((r) => r["id"] === id)?.["priority"];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("custom-fields cross-tenant isolation — set/clear write", () => {
|
|
139
|
+
beforeEach(async () => {
|
|
140
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
|
|
141
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_xt_set_properties`);
|
|
142
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
143
|
+
|
|
144
|
+
// Both tenants independently define their own "priority" field and own a row.
|
|
145
|
+
await defineField(adminA, "priority");
|
|
146
|
+
await defineField(adminB, "priority");
|
|
147
|
+
await createProperty(adminA, PROP_A, "A-Prop");
|
|
148
|
+
await createProperty(adminB, PROP_B, "B-Prop");
|
|
149
|
+
await setField(adminA, PROP_A, "priority", "A-value");
|
|
150
|
+
await setField(adminB, PROP_B, "priority", "B-value");
|
|
151
|
+
await stack.eventDispatcher?.runOnce();
|
|
152
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("tenant A cannot overwrite tenant B's row via a known entityId (set)", async () => {
|
|
156
|
+
// The set-handler runs as A (its own field-definition + RBAC pass) and emits
|
|
157
|
+
// on A's stream; the projection's tenant filter must keep it off B's row.
|
|
158
|
+
await setField(adminA, PROP_B, "priority", "HACKED");
|
|
159
|
+
await stack.eventDispatcher?.runOnce();
|
|
160
|
+
|
|
161
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("tenant A cannot clear tenant B's row via a known entityId (clear)", async () => {
|
|
165
|
+
await clearField(adminA, PROP_B, "priority");
|
|
166
|
+
await stack.eventDispatcher?.runOnce();
|
|
167
|
+
|
|
168
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a tenant's own set still applies (filter does not block the legitimate path)", async () => {
|
|
172
|
+
await setField(adminA, PROP_A, "priority", "A-updated");
|
|
173
|
+
await stack.eventDispatcher?.runOnce();
|
|
174
|
+
|
|
175
|
+
expect(await priorityOf(adminA, PROP_A)).toBe("A-updated");
|
|
176
|
+
expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -266,3 +266,62 @@ describe("T1.5b: per-field fieldAccess.write rejects users without required role
|
|
|
266
266
|
expect(err.code).toBe("not_found");
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
// A row whose serialized_field is corrupt (DB corruption / partial write)
|
|
271
|
+
// must NOT silently drop the per-field write gate. Before the fix the access
|
|
272
|
+
// check fell open ({ ok: true }) and the write went through unvalidated; now
|
|
273
|
+
// it fails closed with a distinct reason.
|
|
274
|
+
describe("fail-closed on corrupt serialized_field", () => {
|
|
275
|
+
async function corruptStoredDefinition(fieldKey: string) {
|
|
276
|
+
await asRawClient(stack.db).unsafe(
|
|
277
|
+
`UPDATE read_custom_field_definitions SET serialized_field = '{not valid json' WHERE field_key = $1`,
|
|
278
|
+
[fieldKey],
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
test("set: corrupt definition row → unprocessable field_definition_corrupt (not silent allow)", async () => {
|
|
283
|
+
const propertyId = "77777777-7777-4000-8000-000000000007";
|
|
284
|
+
await defineField("corruptField", { type: "text", fieldAccess: { write: ["TenantAdmin"] } });
|
|
285
|
+
await corruptStoredDefinition("corruptField");
|
|
286
|
+
await stack.http.writeOk(
|
|
287
|
+
"property-t15b:write:property:create",
|
|
288
|
+
{ id: propertyId, name: "Corrupt" },
|
|
289
|
+
tenantAdmin,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const err = await stack.http.writeErr(
|
|
293
|
+
"custom-fields:write:set-custom-field",
|
|
294
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "corruptField", value: "X-42" },
|
|
295
|
+
tenantAdmin,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
expect(err.code).toBe("unprocessable");
|
|
299
|
+
expect(err.details).toMatchObject({
|
|
300
|
+
reason: "field_definition_corrupt",
|
|
301
|
+
fieldKey: "corruptField",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("clear: corrupt definition row → unprocessable field_definition_corrupt", async () => {
|
|
306
|
+
const propertyId = "88888888-8888-4000-8000-000000000008";
|
|
307
|
+
await defineField("corruptField", { type: "boolean" });
|
|
308
|
+
await corruptStoredDefinition("corruptField");
|
|
309
|
+
await stack.http.writeOk(
|
|
310
|
+
"property-t15b:write:property:create",
|
|
311
|
+
{ id: propertyId, name: "Corrupt2" },
|
|
312
|
+
tenantAdmin,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const err = await stack.http.writeErr(
|
|
316
|
+
"custom-fields:write:clear-custom-field",
|
|
317
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "corruptField" },
|
|
318
|
+
tenantAdmin,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(err.code).toBe("unprocessable");
|
|
322
|
+
expect(err.details).toMatchObject({
|
|
323
|
+
reason: "field_definition_corrupt",
|
|
324
|
+
fieldKey: "corruptField",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCustomFieldValueSchema } from "../lib/value-schema";
|
|
3
|
+
|
|
4
|
+
describe("buildCustomFieldValueSchema — type-shape only", () => {
|
|
5
|
+
test("strips top-level constraint keys (required/maxLength/format)", () => {
|
|
6
|
+
const schema = buildCustomFieldValueSchema({
|
|
7
|
+
type: "text",
|
|
8
|
+
required: true,
|
|
9
|
+
maxLength: 5,
|
|
10
|
+
format: "email",
|
|
11
|
+
});
|
|
12
|
+
expect(schema).not.toBeNull();
|
|
13
|
+
expect(schema?.safeParse("").success).toBe(true);
|
|
14
|
+
expect(schema?.safeParse("not-an-email-and-way-too-long").success).toBe(true);
|
|
15
|
+
expect(schema?.safeParse(42).success).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("embedded validates sub-field TYPE only — sub-field `required` is stripped", () => {
|
|
19
|
+
const schema = buildCustomFieldValueSchema({
|
|
20
|
+
type: "embedded",
|
|
21
|
+
schema: { city: { type: "text", required: true } },
|
|
22
|
+
});
|
|
23
|
+
expect(schema).not.toBeNull();
|
|
24
|
+
// type-valid objects pass, regardless of the sub-field `required` flag
|
|
25
|
+
expect(schema?.safeParse({ city: "Bonn" }).success).toBe(true);
|
|
26
|
+
expect(schema?.safeParse({}).success).toBe(true);
|
|
27
|
+
expect(schema?.safeParse({ city: "" }).success).toBe(true);
|
|
28
|
+
// type-mismatches still rejected: non-object, and wrong sub-field type
|
|
29
|
+
expect(schema?.safeParse("not-an-object").success).toBe(false);
|
|
30
|
+
expect(schema?.safeParse({ city: 123 }).success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("embedded strip applies to non-text sub-types too (number)", () => {
|
|
34
|
+
const schema = buildCustomFieldValueSchema({
|
|
35
|
+
type: "embedded",
|
|
36
|
+
schema: { age: { type: "number", required: true } },
|
|
37
|
+
});
|
|
38
|
+
expect(schema).not.toBeNull();
|
|
39
|
+
expect(schema?.safeParse({ age: 7 }).success).toBe(true);
|
|
40
|
+
// required stripped → missing key passes (pre-fix the non-optional number
|
|
41
|
+
// sub-field rejected it)
|
|
42
|
+
expect(schema?.safeParse({}).success).toBe(true);
|
|
43
|
+
// type-mismatch still rejected
|
|
44
|
+
expect(schema?.safeParse({ age: "not-a-number" }).success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("embedded with an unsupported sub-type → null (skip validation)", () => {
|
|
48
|
+
const schema = buildCustomFieldValueSchema({
|
|
49
|
+
type: "embedded",
|
|
50
|
+
schema: { blob: { type: "json" } },
|
|
51
|
+
});
|
|
52
|
+
expect(schema).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -13,19 +13,22 @@ function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
|
|
|
13
13
|
return { sql: "$1::jsonb", bound: value };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Security invariant: aggregateId is a global row UUID, so without the tenant_id
|
|
17
|
+
// filter tenant A could mutate tenant B's row by its UUID (cf. removeCustomFieldKeyForTenant).
|
|
16
18
|
export async function setCustomFieldValue(
|
|
17
19
|
db: DbRunner,
|
|
18
20
|
tableName: string,
|
|
19
21
|
fieldKey: string,
|
|
20
22
|
value: unknown,
|
|
21
23
|
aggregateId: string,
|
|
24
|
+
tenantId: string,
|
|
22
25
|
): Promise<void> {
|
|
23
26
|
const tbl = quoteTable(tableName);
|
|
24
27
|
const escapedKey = fieldKey.replace(/'/g, "''");
|
|
25
28
|
const jsonb = bindJsonbParam(value);
|
|
26
29
|
await asRawClient(db).unsafe(
|
|
27
|
-
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
|
|
28
|
-
[jsonb.bound, aggregateId],
|
|
30
|
+
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2 AND tenant_id = $3`,
|
|
31
|
+
[jsonb.bound, aggregateId, tenantId],
|
|
29
32
|
);
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -34,11 +37,12 @@ export async function clearCustomFieldKey(
|
|
|
34
37
|
tableName: string,
|
|
35
38
|
fieldKey: string,
|
|
36
39
|
aggregateId: string,
|
|
40
|
+
tenantId: string,
|
|
37
41
|
): Promise<void> {
|
|
38
42
|
const tbl = quoteTable(tableName);
|
|
39
43
|
await asRawClient(db).unsafe(
|
|
40
|
-
`UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2`,
|
|
41
|
-
[fieldKey, aggregateId],
|
|
44
|
+
`UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2 AND tenant_id = $3`,
|
|
45
|
+
[fieldKey, aggregateId, tenantId],
|
|
42
46
|
);
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -36,6 +36,9 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
36
36
|
if (accessCheck.reason === "field_definition_not_found") {
|
|
37
37
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
38
38
|
}
|
|
39
|
+
if (accessCheck.reason === "field_definition_corrupt") {
|
|
40
|
+
return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
|
|
41
|
+
}
|
|
39
42
|
return failUnprocessable("field_access_denied", {
|
|
40
43
|
fieldKey: payload.fieldKey,
|
|
41
44
|
requiredRoles: accessCheck.requiredRoles ?? [],
|
|
@@ -13,7 +13,11 @@ export const setCustomFieldPayloadSchema = z.object({
|
|
|
13
13
|
.min(1)
|
|
14
14
|
.max(64)
|
|
15
15
|
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
|
|
16
|
-
|
|
16
|
+
// z.unknown() is implicitly optional; reject a missing value here (clearing is
|
|
17
|
+
// clear-custom-field's job) so the projection never binds JSON.stringify(undefined).
|
|
18
|
+
value: z
|
|
19
|
+
.unknown()
|
|
20
|
+
.refine((v) => v !== undefined, "value is required (use clear-custom-field to remove a value)"),
|
|
17
21
|
});
|
|
18
22
|
export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
19
23
|
|
|
@@ -57,6 +61,9 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
57
61
|
if (!loaded.found) {
|
|
58
62
|
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
59
63
|
}
|
|
64
|
+
if (loaded.field === null) {
|
|
65
|
+
return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
|
|
66
|
+
}
|
|
60
67
|
|
|
61
68
|
const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, event.user.roles);
|
|
62
69
|
if (deniedRoles) {
|
|
@@ -10,15 +10,15 @@ export type FieldAccessCheckResult =
|
|
|
10
10
|
| { ok: true }
|
|
11
11
|
| {
|
|
12
12
|
ok: false;
|
|
13
|
-
reason: "field_definition_not_found" | "field_access_denied";
|
|
13
|
+
reason: "field_definition_not_found" | "field_definition_corrupt" | "field_access_denied";
|
|
14
14
|
requiredRoles?: ReadonlyArray<string>;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export type LoadedFieldDefinition =
|
|
18
18
|
| { found: false }
|
|
19
|
-
// `field` is null when the row exists but its serialized_field is corrupt
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// `field` is null when the row exists but its serialized_field is corrupt.
|
|
20
|
+
// A write access-gate treats this fail-closed (secure-by-default): a corrupt
|
|
21
|
+
// definition must not silently drop a per-field write restriction.
|
|
22
22
|
| { found: true; field: SerializedFieldShape | null };
|
|
23
23
|
|
|
24
24
|
export async function loadFieldDefinition(
|
|
@@ -54,6 +54,7 @@ export async function checkFieldAccessForWrite(
|
|
|
54
54
|
): Promise<FieldAccessCheckResult> {
|
|
55
55
|
const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
|
|
56
56
|
if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
|
|
57
|
+
if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
|
|
57
58
|
|
|
58
59
|
const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, userRoles);
|
|
59
60
|
if (!deniedRoles) return { ok: true };
|
|
@@ -47,20 +47,32 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
|
|
|
47
47
|
// Embedded sub-fields: pre-check the sub-type set so we surface unknown
|
|
48
48
|
// sub-types as "skip validation" (return null) rather than letting
|
|
49
49
|
// fieldToZod's assertUnreachable throw and the catch swallow real bugs.
|
|
50
|
+
// Build a constraint-stripped copy of each sub-field in the same pass —
|
|
51
|
+
// symmetric with the top-level strip below. Without it a sub-field's
|
|
52
|
+
// `required` folds into z.string().min(1) + non-optional, re-enforcing a
|
|
53
|
+
// constraint the contract ("type-mismatches and ONLY type-mismatches")
|
|
54
|
+
// drops everywhere else.
|
|
55
|
+
let strippedSubSchema: Record<string, Record<string, unknown>> | undefined;
|
|
50
56
|
if (rawType === "embedded") {
|
|
51
57
|
const schema = obj["schema"];
|
|
52
58
|
if (!schema || typeof schema !== "object") return null;
|
|
53
|
-
|
|
59
|
+
strippedSubSchema = {};
|
|
60
|
+
for (const [subKey, sub] of Object.entries(schema)) {
|
|
54
61
|
if (!sub || typeof sub !== "object") return null;
|
|
55
|
-
const
|
|
62
|
+
const subObj = sub as Record<string, unknown>;
|
|
63
|
+
const subType = subObj["type"];
|
|
56
64
|
if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
|
|
57
65
|
return null;
|
|
58
66
|
}
|
|
67
|
+
const strippedSub = { ...subObj };
|
|
68
|
+
for (const k of CONSTRAINT_KEYS) delete strippedSub[k];
|
|
69
|
+
strippedSubSchema[subKey] = strippedSub;
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
const fieldDef: Record<string, unknown> = { ...obj };
|
|
63
74
|
for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
|
|
75
|
+
if (strippedSubSchema !== undefined) fieldDef["schema"] = strippedSubSchema;
|
|
64
76
|
if (rawType === "enum") {
|
|
65
77
|
fieldDef["type"] = "select";
|
|
66
78
|
fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
|
|
@@ -114,6 +114,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
114
114
|
payload.fieldKey,
|
|
115
115
|
payload.value,
|
|
116
116
|
event.aggregateId,
|
|
117
|
+
event.tenantId,
|
|
117
118
|
);
|
|
118
119
|
},
|
|
119
120
|
[clearedEventType]: async (event, tx) => {
|
|
@@ -124,7 +125,13 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
124
125
|
|
|
125
126
|
// jsonb minus operator (`-`) entfernt key aus jsonb-object.
|
|
126
127
|
const tableName = getTableName(entityTable);
|
|
127
|
-
await clearCustomFieldKey(
|
|
128
|
+
await clearCustomFieldKey(
|
|
129
|
+
tx,
|
|
130
|
+
tableName,
|
|
131
|
+
payload.fieldKey,
|
|
132
|
+
event.aggregateId,
|
|
133
|
+
event.tenantId,
|
|
134
|
+
);
|
|
128
135
|
},
|
|
129
136
|
[fieldDefDeletedType]: async (event, tx) => {
|
|
130
137
|
// fieldDefinition.deleted fires nur einmal pro fieldDef-delete
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
import { asRawClient, deleteMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import {
|
|
18
19
|
defineFeature,
|
|
19
20
|
EXT_USER_DATA,
|
|
@@ -60,17 +61,16 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
|
60
61
|
const NOW = (): Instant => getTemporal().Now.instant();
|
|
61
62
|
const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
64
|
+
// Synthetic third-party "note" table — unmanaged (no entity-system base
|
|
65
|
+
// columns). deleteMany only filters on tenant_id + author_id, so those are the
|
|
66
|
+
// only columns the query layer needs to know about.
|
|
67
|
+
const testNotesTable = defineUnmanagedTable({
|
|
68
|
+
tableName: "test_notes",
|
|
69
|
+
columns: [
|
|
70
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
71
|
+
{ name: "author_id", pgType: "text", notNull: true },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
74
|
|
|
75
75
|
// Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
|
|
76
76
|
// Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Forget-Hook binary-Cleanup Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist, dass der `fileRef`-Forget-Hook bei strategy="delete" die
|
|
4
|
+
// Storage-Binaries via `storageProvider.delete()` entfernt, BEVOR die
|
|
5
|
+
// row hard-gelöscht wird — ohne provider leakt sonst jede gelöschte
|
|
6
|
+
// Datei ihre Bytes dauerhaft auf Disk (Issue gefunden im Review zu #177).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
createInMemoryFileProvider,
|
|
13
|
+
fileRefsTable,
|
|
14
|
+
type InMemoryFileProvider,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
unsafePushTables,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
26
|
+
import { createFilesFeature } from "../../files";
|
|
27
|
+
import { createSessionsFeature } from "../../sessions";
|
|
28
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
29
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
30
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
31
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
let db: DbConnection;
|
|
35
|
+
let provider: InMemoryFileProvider;
|
|
36
|
+
|
|
37
|
+
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
38
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
39
|
+
|
|
40
|
+
function uuid(suffix: number): string {
|
|
41
|
+
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
45
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
46
|
+
const pastInstant = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
provider = createInMemoryFileProvider();
|
|
50
|
+
stack = await setupTestStack({
|
|
51
|
+
features: [
|
|
52
|
+
createUserFeature(),
|
|
53
|
+
createFilesFeature(),
|
|
54
|
+
createDataRetentionFeature(),
|
|
55
|
+
createComplianceProfilesFeature(),
|
|
56
|
+
createSessionsFeature(),
|
|
57
|
+
createUserDataRightsFeature(),
|
|
58
|
+
createUserDataRightsDefaultsFeature({ storageProvider: provider }),
|
|
59
|
+
],
|
|
60
|
+
files: { storageProvider: provider },
|
|
61
|
+
});
|
|
62
|
+
db = stack.db;
|
|
63
|
+
|
|
64
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
65
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
66
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
67
|
+
await asRawClient(db).unsafe(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
69
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
70
|
+
tenant_id UUID NOT NULL,
|
|
71
|
+
user_id TEXT NOT NULL,
|
|
72
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
74
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
75
|
+
inserted_by_id TEXT,
|
|
76
|
+
modified_by_id TEXT,
|
|
77
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
78
|
+
deleted_at TIMESTAMPTZ,
|
|
79
|
+
deleted_by_id TEXT,
|
|
80
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
81
|
+
UNIQUE(user_id, tenant_id)
|
|
82
|
+
)
|
|
83
|
+
`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await stack.cleanup();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
provider.clear();
|
|
92
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function seedForgetUser(id: string): Promise<void> {
|
|
96
|
+
await insertOne(db, userTable, {
|
|
97
|
+
id,
|
|
98
|
+
tenantId: TENANT_SYSTEM,
|
|
99
|
+
email: `user-${id}@example.com`,
|
|
100
|
+
passwordHash: "hashed",
|
|
101
|
+
displayName: `User ${id}`,
|
|
102
|
+
locale: "de",
|
|
103
|
+
emailVerified: true,
|
|
104
|
+
roles: '["Member"]',
|
|
105
|
+
status: USER_STATUS.DeletionRequested,
|
|
106
|
+
gracePeriodEnd: pastInstant(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
111
|
+
await asRawClient(db).unsafe(
|
|
112
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
113
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
114
|
+
[tenantId, userId],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function seedFile(id: string, tenantId: string, insertedById: string): Promise<string> {
|
|
119
|
+
const storageKey = `storage/${id}`;
|
|
120
|
+
await provider.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
|
|
121
|
+
await asRawClient(db).unsafe(
|
|
122
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
123
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
|
|
124
|
+
[id, tenantId, storageKey, `${id}.pdf`, insertedById],
|
|
125
|
+
);
|
|
126
|
+
return storageKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("forget-binary-cleanup :: storage.delete fires before row hard-delete", () => {
|
|
130
|
+
test("Forget deletes the binary from the storage provider", async () => {
|
|
131
|
+
const userId = uuid(1);
|
|
132
|
+
await seedForgetUser(userId);
|
|
133
|
+
await seedMembership(userId, TENANT);
|
|
134
|
+
const key = await seedFile(uuid(101), TENANT, userId);
|
|
135
|
+
expect(await provider.exists(key)).toBe(true);
|
|
136
|
+
|
|
137
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
138
|
+
|
|
139
|
+
expect(result.processedUserIds).toContain(userId);
|
|
140
|
+
expect(await provider.exists(key)).toBe(false);
|
|
141
|
+
expect(provider.keys()).not.toContain(key);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Multiple files from the same user — all binaries cleaned up", async () => {
|
|
145
|
+
const userId = uuid(2);
|
|
146
|
+
await seedForgetUser(userId);
|
|
147
|
+
await seedMembership(userId, TENANT);
|
|
148
|
+
const keys = await Promise.all([
|
|
149
|
+
seedFile(uuid(201), TENANT, userId),
|
|
150
|
+
seedFile(uuid(202), TENANT, userId),
|
|
151
|
+
seedFile(uuid(203), TENANT, userId),
|
|
152
|
+
]);
|
|
153
|
+
expect(provider.keys()).toHaveLength(3);
|
|
154
|
+
|
|
155
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
156
|
+
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
expect(await provider.exists(key)).toBe(false);
|
|
159
|
+
}
|
|
160
|
+
expect(provider.keys()).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Other tenants' files stay untouched", async () => {
|
|
164
|
+
const userId = uuid(3);
|
|
165
|
+
const otherTenant = "00000000-0000-4000-8000-00000000000d";
|
|
166
|
+
await seedForgetUser(userId);
|
|
167
|
+
await seedMembership(userId, TENANT);
|
|
168
|
+
const myKey = await seedFile(uuid(301), TENANT, userId);
|
|
169
|
+
const otherKey = await seedFile(uuid(302), otherTenant, "another-user");
|
|
170
|
+
// The other-tenant file is owned by a different user; the forget run for
|
|
171
|
+
// userId must NOT touch it.
|
|
172
|
+
|
|
173
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
174
|
+
|
|
175
|
+
expect(await provider.exists(myKey)).toBe(false);
|
|
176
|
+
expect(await provider.exists(otherKey)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -3,9 +3,20 @@ import {
|
|
|
3
3
|
EXT_USER_DATA,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import {
|
|
6
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
7
|
+
import { createFileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
7
8
|
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
8
9
|
|
|
10
|
+
export interface UserDataRightsDefaultsOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Wired into the fileRef delete-hook: on strategy="delete" the hook
|
|
13
|
+
* calls `storageProvider.delete(key)` per row before hard-deleting
|
|
14
|
+
* the row. Without it, file binaries leak on forget (Art. 17) — the
|
|
15
|
+
* hook logs a one-shot warning so misconfiguration stays visible.
|
|
16
|
+
*/
|
|
17
|
+
readonly storageProvider?: FileStorageProvider;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
10
21
|
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
11
22
|
//
|
|
@@ -23,7 +34,10 @@ import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
|
23
34
|
// Pattern matched file-foundation + file-provider-s3 (separate Plugin-
|
|
24
35
|
// Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
|
|
25
36
|
// das circular-requires waere.
|
|
26
|
-
export function createUserDataRightsDefaultsFeature(
|
|
37
|
+
export function createUserDataRightsDefaultsFeature(
|
|
38
|
+
options: UserDataRightsDefaultsOptions = {},
|
|
39
|
+
): FeatureDefinition {
|
|
40
|
+
const fileRefDeleteHook = createFileRefDeleteHook(options.storageProvider);
|
|
27
41
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
28
42
|
r.requires("user", "files", "user-data-rights");
|
|
29
43
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
3
|
+
import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
4
4
|
|
|
5
5
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
6
6
|
//
|
|
@@ -9,10 +9,9 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
9
9
|
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
10
10
|
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
11
11
|
//
|
|
12
|
-
// Delete-Hook entfernt FileRef-Zeile
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// "delete": Row hard-delete + storageProvider.delete() pro File
|
|
12
|
+
// Delete-Hook entfernt FileRef-Zeile via factory
|
|
13
|
+
// `createFileRefDeleteHook(storageProvider)`:
|
|
14
|
+
// "delete": storageProvider.delete() pro File (best-effort) + Row hard-delete
|
|
16
15
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
17
16
|
// koennen weiter zeigen; Personenbezug raus)
|
|
18
17
|
//
|
|
@@ -22,12 +21,17 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
22
21
|
// idempotent, KEIN globaler Rollback — wenn ein File-Delete failt,
|
|
23
22
|
// bleibt der User-Row trotzdem anonymisiert.
|
|
24
23
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
24
|
+
// `storageProvider` ist optional. App-Author wired es beim
|
|
25
|
+
// Feature-Mount rein (`createUserDataRightsDefaultsFeature({
|
|
26
|
+
// storageProvider })`). Ohne Provider macht der Hook row-only-delete,
|
|
27
|
+
// die Bytes leaken — der Caller bekommt EINEN Warn beim ersten Lauf
|
|
28
|
+
// pro Process, damit die Konfiguration sichtbar fehlerhaft ist.
|
|
29
|
+
//
|
|
30
|
+
// Caveat: hard-delete via deleteMany emittiert KEIN fileRef.deleted —
|
|
31
|
+
// die storage-tracking-MSP dekrementiert nicht. Wenn die zu loeschenden
|
|
32
|
+
// Files vorher nicht soft-deleted waren, bleibt `tenant_storage_usage`
|
|
33
|
+
// inflated. Forget-Flows sind selten (per-User-Art.-17) und damit
|
|
34
|
+
// bounded; ein executor.purge-API folgt mit dem trashed-files-GC.
|
|
31
35
|
|
|
32
36
|
export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
33
37
|
// isDeleted:false — soft-deleted (trashed) Files gehören nicht ins
|
|
@@ -76,22 +80,54 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
76
80
|
};
|
|
77
81
|
};
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
83
|
+
let missingStorageWarned = false;
|
|
84
|
+
|
|
85
|
+
export function createFileRefDeleteHook(
|
|
86
|
+
storageProvider: FileStorageProvider | undefined,
|
|
87
|
+
): UserDataDeleteHook {
|
|
88
|
+
return async (ctx, strategy) => {
|
|
89
|
+
if (strategy === "delete") {
|
|
90
|
+
if (storageProvider) {
|
|
91
|
+
const rows = await selectMany(ctx.db, fileRefsTable, {
|
|
92
|
+
tenantId: ctx.tenantId,
|
|
93
|
+
insertedById: ctx.userId,
|
|
94
|
+
});
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
97
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
98
|
+
try {
|
|
99
|
+
await storageProvider.delete(key);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
102
|
+
console.warn(
|
|
103
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (!missingStorageWarned) {
|
|
108
|
+
missingStorageWarned = true;
|
|
109
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
110
|
+
console.warn(
|
|
111
|
+
"[user-data-rights-defaults:fileRef] no storageProvider configured — file binaries are NOT deleted on forget. Pass createUserDataRightsDefaultsFeature({ storageProvider }) to fix.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
115
|
+
} else {
|
|
116
|
+
// anonymize: insertedById=null, FileRef + binary bleiben.
|
|
117
|
+
// Use-case: shared chat-Attachment in einem Multi-User-Channel —
|
|
118
|
+
// Author-Identifikation raus, Datei bleibt fuer andere User
|
|
119
|
+
// sichtbar.
|
|
120
|
+
await updateMany(
|
|
121
|
+
ctx.db,
|
|
122
|
+
fileRefsTable,
|
|
123
|
+
{ insertedById: null },
|
|
124
|
+
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Legacy export: storage-less hook for callers that haven't migrated.
|
|
131
|
+
// Binaries are NOT cleaned up — disk leak. Migrate to
|
|
132
|
+
// createUserDataRightsDefaultsFeature({ storageProvider }).
|
|
133
|
+
export const fileRefDeleteHook: UserDataDeleteHook = createFileRefDeleteHook(undefined);
|