@cosmicdrift/kumiko-bundled-features 0.215.0 → 0.215.2
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 +8 -8
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +142 -5
- package/src/crypto-shredding/constants.ts +2 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +72 -5
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +38 -0
- package/src/tenant-settings/feature.ts +18 -3
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +75 -0
- package/src/user-data-rights/index.ts +5 -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.215.
|
|
3
|
+
"version": "0.215.2",
|
|
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>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.215.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.215.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.215.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.215.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.215.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.215.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.2",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.215.2",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.215.2",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.215.2",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.215.2",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.215.2",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -160,6 +160,6 @@
|
|
|
160
160
|
"devDependencies": {
|
|
161
161
|
"@testing-library/user-event": "^14.6.1",
|
|
162
162
|
"@types/qrcode": "^1.5.5",
|
|
163
|
-
"@cosmicdrift/kumiko-locale-de": "0.215.
|
|
163
|
+
"@cosmicdrift/kumiko-locale-de": "0.215.2"
|
|
164
164
|
}
|
|
165
165
|
}
|
|
@@ -40,6 +40,7 @@ import { apiTokenEntity, apiTokenTable } from "../../personal-access-tokens/sche
|
|
|
40
40
|
import { createTenantFeature } from "../../tenant";
|
|
41
41
|
import { tenantInvitationEntity } from "../../tenant/invitation-table";
|
|
42
42
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
43
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
43
44
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
44
45
|
import { createUserFeature } from "../../user/feature";
|
|
45
46
|
import { seedUser } from "../../user/seeding";
|
|
@@ -116,20 +117,47 @@ describe("crypto-shredding :: forget-subject", () => {
|
|
|
116
117
|
});
|
|
117
118
|
});
|
|
118
119
|
|
|
119
|
-
test("tenant subject shreds the same way", async () => {
|
|
120
|
-
const subject = { kind: "tenant", tenantId:
|
|
121
|
-
await kms.createKey({ kind: "tenant", tenantId:
|
|
120
|
+
test("tenant subject shreds the same way (DPO's own tenant)", async () => {
|
|
121
|
+
const subject = { kind: "tenant", tenantId: TENANT } as const;
|
|
122
|
+
await kms.createKey({ kind: "tenant", tenantId: TENANT });
|
|
122
123
|
|
|
123
124
|
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
124
125
|
FORGET,
|
|
125
126
|
{ subject, reason: REASON },
|
|
126
127
|
dpoUser,
|
|
127
128
|
);
|
|
128
|
-
expect(result.subjectKey).toBe(`tenant:${
|
|
129
|
+
expect(result.subjectKey).toBe(`tenant:${TENANT}`);
|
|
130
|
+
|
|
131
|
+
await expect(kms.getKey({ kind: "tenant", tenantId: TENANT })).rejects.toThrow(
|
|
132
|
+
"Subject key erased",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// mh#349: DataProtectionOfficer is tenant-scoped — a DPO who learns a
|
|
137
|
+
// foreign tenant's id (export, support ticket, log line) must not be able
|
|
138
|
+
// to destroy that tenant's subject key.
|
|
139
|
+
test("DPO cannot forget a different tenant's subject → 403", async () => {
|
|
140
|
+
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
141
|
+
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
142
|
+
|
|
143
|
+
const err = await stack.http.writeErr(FORGET, { subject, reason: REASON }, dpoUser);
|
|
144
|
+
expect(err.httpStatus).toBe(403);
|
|
129
145
|
|
|
130
146
|
await expect(
|
|
131
147
|
kms.getKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId }),
|
|
132
|
-
).
|
|
148
|
+
).resolves.toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("SystemAdmin bypasses the tenant-scope guard", async () => {
|
|
152
|
+
const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
|
|
153
|
+
await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
|
|
154
|
+
|
|
155
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
156
|
+
FORGET,
|
|
157
|
+
{ subject, reason: REASON },
|
|
158
|
+
TestUsers.systemAdmin,
|
|
159
|
+
);
|
|
160
|
+
expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
|
|
133
161
|
});
|
|
134
162
|
|
|
135
163
|
test("repeat forget: erase is a no-op but each attempt is audited", async () => {
|
|
@@ -307,6 +335,31 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
307
335
|
let kms: InMemoryKmsAdapter;
|
|
308
336
|
const TENANT_B = testTenantId(3);
|
|
309
337
|
|
|
338
|
+
// mh#349: a "user"-kind subject isn't always a real user — a share-token
|
|
339
|
+
// recipient or email subscriber self-owns its PII the same way
|
|
340
|
+
// (personal: "self", its own row id is the subject) but has no tenant-
|
|
341
|
+
// membership row. The guard must fall back to the subject row's own
|
|
342
|
+
// tenant_id for these.
|
|
343
|
+
const shareLikeEntity = createEntity({
|
|
344
|
+
table: "read_forget_subject_share_like_probe",
|
|
345
|
+
fields: {
|
|
346
|
+
recipientName: createTextField({
|
|
347
|
+
required: true,
|
|
348
|
+
maxLength: 100,
|
|
349
|
+
personal: "self",
|
|
350
|
+
find: "exact",
|
|
351
|
+
}),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const shareLikeTable = buildEntityTable("forgetSubjectShareLikeProbe", shareLikeEntity);
|
|
355
|
+
const shareLikeFeature = defineFeature("forget-subject-share-like-probe", (r) => {
|
|
356
|
+
r.entity("probe", shareLikeEntity);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
function shareLikeProbeExecutor() {
|
|
360
|
+
return createEventStoreExecutor(shareLikeTable, shareLikeEntity, { entityName: "probe" });
|
|
361
|
+
}
|
|
362
|
+
|
|
310
363
|
beforeAll(async () => {
|
|
311
364
|
stack = await setupTestStack({
|
|
312
365
|
features: [
|
|
@@ -316,6 +369,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
316
369
|
createConfigFeature(),
|
|
317
370
|
authFoundationFeature,
|
|
318
371
|
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
372
|
+
shareLikeFeature,
|
|
319
373
|
],
|
|
320
374
|
});
|
|
321
375
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
@@ -323,6 +377,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
323
377
|
// tenant feature's only lookupable entity — the blind-index sweep
|
|
324
378
|
// touches it; without the table the handler 500s on `read_tenant_invitations`.
|
|
325
379
|
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
380
|
+
await unsafeCreateEntityTable(stack.db, shareLikeEntity, "probe");
|
|
326
381
|
await unsafePushTables(stack.db, { tenantMembershipsTable });
|
|
327
382
|
await createEventsTable(stack.db);
|
|
328
383
|
});
|
|
@@ -336,6 +391,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
336
391
|
userTable,
|
|
337
392
|
apiTokenTable,
|
|
338
393
|
tenantMembershipsTable,
|
|
394
|
+
shareLikeTable,
|
|
339
395
|
eventsTable,
|
|
340
396
|
]);
|
|
341
397
|
kms = new InMemoryKmsAdapter();
|
|
@@ -352,6 +408,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
352
408
|
displayName: "Forgotten User",
|
|
353
409
|
emailVerified: true,
|
|
354
410
|
});
|
|
411
|
+
await seedTenantMembership(stack.db, { userId, tenantId: TENANT, roles: ["Member"] });
|
|
355
412
|
// No explicit createKey: seedUser's PII-encrypt (email) already created
|
|
356
413
|
// the subject key implicitly via getOrCreateDek.
|
|
357
414
|
await insertOne(stack.db, apiTokenTable, {
|
|
@@ -378,4 +435,84 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
|
|
|
378
435
|
expect(tokens).toHaveLength(1);
|
|
379
436
|
expect(tokens[0]?.["revokedAt"]).not.toBeNull();
|
|
380
437
|
});
|
|
438
|
+
|
|
439
|
+
// mh#349: a DPO must not be able to forget a user who has no membership in
|
|
440
|
+
// their own tenant (e.g. a Tenant-A DPO who learned a Tenant-B user id).
|
|
441
|
+
test("DPO cannot forget a user with no membership in their tenant → 403", async () => {
|
|
442
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
443
|
+
email: "foreign-tenant-user@example.com",
|
|
444
|
+
displayName: "Foreign Tenant User",
|
|
445
|
+
emailVerified: true,
|
|
446
|
+
});
|
|
447
|
+
await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
|
|
448
|
+
|
|
449
|
+
const err = await stack.http.writeErr(
|
|
450
|
+
FORGET,
|
|
451
|
+
{ subject: { kind: "user", userId }, reason: REASON },
|
|
452
|
+
dpoUser,
|
|
453
|
+
);
|
|
454
|
+
expect(err.httpStatus).toBe(403);
|
|
455
|
+
|
|
456
|
+
const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
|
|
457
|
+
expect(userRow?.["status"]).not.toBe(USER_STATUS.Deleted);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("SystemAdmin can forget a user with no membership in any tenant", async () => {
|
|
461
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
462
|
+
email: "system-admin-forgets@example.com",
|
|
463
|
+
displayName: "System Admin Forgets",
|
|
464
|
+
emailVerified: true,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await stack.http.writeOk(
|
|
468
|
+
FORGET,
|
|
469
|
+
{ subject: { kind: "user", userId }, reason: REASON },
|
|
470
|
+
TestUsers.systemAdmin,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
|
|
474
|
+
expect(userRow?.["status"]).toBe(USER_STATUS.Deleted);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("DPO can forget a self-owned PII subject (share-token-like) in their own tenant", async () => {
|
|
478
|
+
const created = await shareLikeProbeExecutor().create(
|
|
479
|
+
{ recipientName: "Tommy Recipient" },
|
|
480
|
+
dpoUser,
|
|
481
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
482
|
+
);
|
|
483
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
484
|
+
const subjectId = String(created.data.id);
|
|
485
|
+
|
|
486
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
487
|
+
FORGET,
|
|
488
|
+
{ subject: { kind: "user", userId: subjectId }, reason: REASON },
|
|
489
|
+
dpoUser,
|
|
490
|
+
);
|
|
491
|
+
expect(result.subjectKey).toBe(`user:${subjectId}`);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("DPO cannot forget another tenant's self-owned PII subject → 403", async () => {
|
|
495
|
+
// create()'s event stream is tenant-scoped to the AUTHOR, not the
|
|
496
|
+
// tenantDb override — an actor whose own tenantId is TENANT_B is
|
|
497
|
+
// required to actually seed the row under the foreign tenant.
|
|
498
|
+
const foreignTenantAuthor = {
|
|
499
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f2",
|
|
500
|
+
tenantId: TENANT_B,
|
|
501
|
+
roles: ["Member"],
|
|
502
|
+
};
|
|
503
|
+
const created = await shareLikeProbeExecutor().create(
|
|
504
|
+
{ recipientName: "Peter Recipient" },
|
|
505
|
+
foreignTenantAuthor,
|
|
506
|
+
createTenantDb(stack.db, TENANT_B, "system"),
|
|
507
|
+
);
|
|
508
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
509
|
+
const subjectId = String(created.data.id);
|
|
510
|
+
|
|
511
|
+
const err = await stack.http.writeErr(
|
|
512
|
+
FORGET,
|
|
513
|
+
{ subject: { kind: "user", userId: subjectId }, reason: REASON },
|
|
514
|
+
dpoUser,
|
|
515
|
+
);
|
|
516
|
+
expect(err.httpStatus).toBe(403);
|
|
517
|
+
});
|
|
381
518
|
});
|
|
@@ -3,3 +3,5 @@ export const CRYPTO_SHREDDING_FEATURE_NAME = "crypto-shredding";
|
|
|
3
3
|
export const SUBJECT_FORGOTTEN_EVENT_NAME = "crypto-shredding:event:subject-forgotten";
|
|
4
4
|
|
|
5
5
|
export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
|
|
6
|
+
|
|
7
|
+
export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
|
|
@@ -5,15 +5,37 @@ import {
|
|
|
5
5
|
type SubjectId,
|
|
6
6
|
subjectIdToKey,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import {
|
|
9
|
+
type DbRunner,
|
|
10
|
+
nullBlindIndexesForSubject,
|
|
11
|
+
subjectRowExistsInTenant,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import {
|
|
14
|
+
defineWriteHandler,
|
|
15
|
+
type FeatureDefinition,
|
|
16
|
+
type SessionUser,
|
|
17
|
+
type TenantId,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import {
|
|
20
|
+
AccessDeniedError,
|
|
21
|
+
InternalError,
|
|
22
|
+
type WriteFailure,
|
|
23
|
+
writeFailure,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
11
25
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
12
26
|
import { z } from "zod";
|
|
13
27
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
|
|
14
28
|
import { USER_STATUS } from "../../user";
|
|
15
|
-
import {
|
|
16
|
-
|
|
29
|
+
import {
|
|
30
|
+
denyIfTargetOutsideAdminTenant,
|
|
31
|
+
isSystemAdminActor,
|
|
32
|
+
updateUserLifecycle,
|
|
33
|
+
} from "../../user-data-rights";
|
|
34
|
+
import {
|
|
35
|
+
CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
36
|
+
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
37
|
+
TARGET_TENANT_NOT_ADMIN_TENANT,
|
|
38
|
+
} from "../constants";
|
|
17
39
|
|
|
18
40
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
19
41
|
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
@@ -31,6 +53,43 @@ export const subjectForgottenSchema = z.object({
|
|
|
31
53
|
forgottenBy: z.string().min(1),
|
|
32
54
|
});
|
|
33
55
|
|
|
56
|
+
type SubjectIdInput = z.infer<typeof subjectIdSchema>;
|
|
57
|
+
|
|
58
|
+
// Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
|
|
59
|
+
// but the handler otherwise erases ANY subject cross-tenant on a raw
|
|
60
|
+
// (un-scoped) client — a Tenant-A DPO who learns a Tenant-B subject id
|
|
61
|
+
// (export, support ticket, log line) could destroy it. SystemAdmin
|
|
62
|
+
// (platform-wide) always bypasses.
|
|
63
|
+
async function resolveTenantScopeDenial(
|
|
64
|
+
db: DbRunner,
|
|
65
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
66
|
+
user: SessionUser,
|
|
67
|
+
raw: SubjectIdInput,
|
|
68
|
+
): Promise<WriteFailure | undefined> {
|
|
69
|
+
if (isSystemAdminActor(user)) return undefined;
|
|
70
|
+
|
|
71
|
+
if (raw.kind === "tenant") {
|
|
72
|
+
if (raw.tenantId === user.tenantId) return undefined;
|
|
73
|
+
return writeFailure(
|
|
74
|
+
new AccessDeniedError({ details: { reason: TARGET_TENANT_NOT_ADMIN_TENANT } }),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Without the tenant feature there's no membership table to check against —
|
|
79
|
+
// no scoping concept exists to enforce (single/no-tenant apps only;
|
|
80
|
+
// ponytail: fail-open here, not a gap in multi-tenant apps).
|
|
81
|
+
if (!features.has("tenant")) return undefined;
|
|
82
|
+
|
|
83
|
+
const memberDenied = await denyIfTargetOutsideAdminTenant(db, user, raw.userId);
|
|
84
|
+
if (!memberDenied) return undefined;
|
|
85
|
+
|
|
86
|
+
// Not a real user in the actor's tenant — raw.userId may still be a
|
|
87
|
+
// self-owned PII subject (share-token recipient, email subscriber, ...)
|
|
88
|
+
// whose own row carries a real tenant_id.
|
|
89
|
+
const ownedInTenant = await subjectRowExistsInTenant(db, features, raw.userId, user.tenantId);
|
|
90
|
+
return ownedInTenant ? undefined : memberDenied;
|
|
91
|
+
}
|
|
92
|
+
|
|
34
93
|
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
35
94
|
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
36
95
|
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
@@ -63,6 +122,14 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
63
122
|
: { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
|
|
64
123
|
const subjectKey = subjectIdToKey(subject);
|
|
65
124
|
|
|
125
|
+
const tenantScopeDenial = await resolveTenantScopeDenial(
|
|
126
|
+
ctx.db.raw,
|
|
127
|
+
ctx.registry.features,
|
|
128
|
+
event.user,
|
|
129
|
+
raw,
|
|
130
|
+
);
|
|
131
|
+
if (tenantScopeDenial) return tenantScopeDenial;
|
|
132
|
+
|
|
66
133
|
// Erase BEFORE the audit append: if the append throws, the key is gone
|
|
67
134
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
68
135
|
// reverse order could leave an audit trail claiming a shred that never
|
|
@@ -55,4 +55,42 @@ describe("tenant-settings — Settings-Hub nav/section labels", () => {
|
|
|
55
55
|
if (sectionTitle === undefined) throw new Error("unreachable");
|
|
56
56
|
expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
test("every Settings-Hub label the schema renders resolves in es, distinct from en", () => {
|
|
60
|
+
const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
|
|
61
|
+
const schema = buildConfigFeatureSchema(registry);
|
|
62
|
+
const translations = registry.getFeature("tenant-settings")?.translations ?? {};
|
|
63
|
+
|
|
64
|
+
const renderedKeys: string[] = [];
|
|
65
|
+
for (const screenId of ["tenant-settings-system", "tenant-settings-tenant"]) {
|
|
66
|
+
const nav = schema.navs.find((n) => n.id === screenId);
|
|
67
|
+
const screen = schema.screens.find((s) => s.id === screenId);
|
|
68
|
+
expect(nav).toBeDefined();
|
|
69
|
+
expect(screen).toBeDefined();
|
|
70
|
+
if (nav === undefined || screen === undefined || screen.type !== "configEdit") {
|
|
71
|
+
throw new Error("unreachable");
|
|
72
|
+
}
|
|
73
|
+
const section = screen.layout.sections[0];
|
|
74
|
+
const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
|
|
75
|
+
expect(sectionTitle).toBeDefined();
|
|
76
|
+
if (sectionTitle === undefined) throw new Error("unreachable");
|
|
77
|
+
|
|
78
|
+
renderedKeys.push(nav.label, `screen:${screenId}.title`, sectionTitle);
|
|
79
|
+
renderedKeys.push(...Object.values(screen.fieldLabels ?? {}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The Settings-Hub renders exactly these label surfaces per scope: nav
|
|
83
|
+
// entry, screen title, section heading and one label per masked config
|
|
84
|
+
// key (currency + locale) — six distinct keys across both scopes.
|
|
85
|
+
expect(new Set(renderedKeys).size).toBe(6);
|
|
86
|
+
|
|
87
|
+
for (const key of renderedKeys) {
|
|
88
|
+
expect(translate(translations, key, "es")).not.toBe(key);
|
|
89
|
+
expect(translate(translations, key, "es")).not.toBe(translate(translations, key, "en"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const systemLabel = translate(translations, "tenant-settings.settings.system", "es");
|
|
93
|
+
const tenantLabel = translate(translations, "tenant-settings.settings", "es");
|
|
94
|
+
expect(systemLabel).not.toBe(tenantLabel);
|
|
95
|
+
});
|
|
58
96
|
});
|
|
@@ -29,21 +29,35 @@ export function createTenantSettingsFeature(
|
|
|
29
29
|
r.config({ keys: buildTenantSettingsKeys(opts) });
|
|
30
30
|
r.translations({
|
|
31
31
|
keys: {
|
|
32
|
-
"tenant-settings.currency": {
|
|
33
|
-
|
|
32
|
+
"tenant-settings.currency": {
|
|
33
|
+
de: "Standard-Währung",
|
|
34
|
+
en: "Default Currency",
|
|
35
|
+
es: "Moneda predeterminada",
|
|
36
|
+
},
|
|
37
|
+
"tenant-settings.locale": {
|
|
38
|
+
de: "Standard-Sprache",
|
|
39
|
+
en: "Default Locale",
|
|
40
|
+
es: "Idioma predeterminado",
|
|
41
|
+
},
|
|
34
42
|
// Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
|
|
35
43
|
// required by the i18n boot-validator whenever a feature masks config keys.
|
|
36
44
|
"screen:tenant-settings-system.title": {
|
|
37
45
|
de: "Tenant-Einstellungen",
|
|
38
46
|
en: "Tenant Settings",
|
|
47
|
+
es: "Ajustes de la organización",
|
|
39
48
|
},
|
|
40
49
|
"screen:tenant-settings-tenant.title": {
|
|
41
50
|
de: "Tenant-Einstellungen",
|
|
42
51
|
en: "Tenant Settings",
|
|
52
|
+
es: "Ajustes de la organización",
|
|
43
53
|
},
|
|
44
54
|
// Settings-Hub nav label + configEdit section heading (same key, both
|
|
45
55
|
// scopes fall back here — see buildConfigFeatureSchema).
|
|
46
|
-
"tenant-settings.settings": {
|
|
56
|
+
"tenant-settings.settings": {
|
|
57
|
+
de: "Tenant-Einstellungen",
|
|
58
|
+
en: "Tenant Settings",
|
|
59
|
+
es: "Ajustes de la organización",
|
|
60
|
+
},
|
|
47
61
|
// Both currency/locale are tenant-home with admin write access, which
|
|
48
62
|
// cascades a SystemAdmin-only "set the platform default" screen up to
|
|
49
63
|
// the system audience — scoped override so its nav entry reads
|
|
@@ -52,6 +66,7 @@ export function createTenantSettingsFeature(
|
|
|
52
66
|
"tenant-settings.settings.system": {
|
|
53
67
|
de: "Tenant-Einstellungen (Plattform-Standard)",
|
|
54
68
|
en: "Tenant Settings (Platform Default)",
|
|
69
|
+
es: "Ajustes de la organización (valor predeterminado de la plataforma)",
|
|
55
70
|
},
|
|
56
71
|
},
|
|
57
72
|
});
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
16
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
17
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
18
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
18
19
|
import {
|
|
19
20
|
createEntity,
|
|
21
|
+
createSystemUser,
|
|
20
22
|
createTextField,
|
|
21
23
|
defineFeature,
|
|
22
24
|
EXT_USER_DATA,
|
|
@@ -38,6 +40,8 @@ import { buildEnvConfigOverrides, createConfigResolver } from "../../config/reso
|
|
|
38
40
|
import { configValueEntity } from "../../config/table";
|
|
39
41
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
40
42
|
import { createSessionsFeature } from "../../sessions";
|
|
43
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
|
|
44
|
+
import { seedTenantMembership } from "../../tenant/seeding";
|
|
41
45
|
import { createUserFeature, userEntity } from "../../user";
|
|
42
46
|
import { TENANT_MODEL_CONFIG_KEY } from "../constants";
|
|
43
47
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -74,6 +78,14 @@ const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
|
|
|
74
78
|
});
|
|
75
79
|
});
|
|
76
80
|
|
|
81
|
+
const membershipExecutor = createEventStoreExecutor(
|
|
82
|
+
tenantMembershipsTable,
|
|
83
|
+
tenantMembershipEntity,
|
|
84
|
+
{
|
|
85
|
+
entityName: "tenant-membership",
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
77
89
|
let stack: TestStack;
|
|
78
90
|
const seed = (db: unknown) =>
|
|
79
91
|
// biome-ignore lint/suspicious/noExplicitAny: dummy writer; this contributor has no binaries.
|
|
@@ -224,6 +236,69 @@ describe("forget pipeline honours the effective tenant model", () => {
|
|
|
224
236
|
expect(result.errors).toHaveLength(0);
|
|
225
237
|
expect(await rowCount()).toBe(1);
|
|
226
238
|
});
|
|
239
|
+
|
|
240
|
+
test("single-user, sole live member, but a departed co-member's history remains → rows preserved (ent#346)", async () => {
|
|
241
|
+
await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c5");
|
|
242
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
243
|
+
// Both memberships go through the REAL event-store executor (not the
|
|
244
|
+
// raw-INSERT seedMembership() helper) — the historical check reads
|
|
245
|
+
// `tenant-membership.created` events, so FORGET_USER needs its own
|
|
246
|
+
// `.created` event too, or the tenant would only ever show 1 distinct
|
|
247
|
+
// historical member (CO_MEMBER) even after it truly had 2.
|
|
248
|
+
// Explicit `by`: streamTenantFor(user) keys each event's OWN tenantId
|
|
249
|
+
// column off the acting user, not the payload's tenantId — the
|
|
250
|
+
// seedTenantMembership default (TestUsers.systemAdmin) lives on a
|
|
251
|
+
// different tenant, which would emit .created under the wrong tenantId
|
|
252
|
+
// and make it invisible to everHadMultipleMembers' tenant-scoped query.
|
|
253
|
+
await seedTenantMembership(stack.db, {
|
|
254
|
+
userId: FORGET_USER,
|
|
255
|
+
tenantId: TENANT,
|
|
256
|
+
roles: ["Member"],
|
|
257
|
+
by: createSystemUser(TENANT),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// CO_MEMBER joins for real (emits tenant-membership.created) and is then
|
|
261
|
+
// removed for real (emits tenant-membership.deleted, deletes the
|
|
262
|
+
// projection row) — mirrors removeMemberWrite exactly, so the created
|
|
263
|
+
// event survives in kumiko_events even though the live row is gone.
|
|
264
|
+
const created = await seedTenantMembership(stack.db, {
|
|
265
|
+
userId: CO_MEMBER,
|
|
266
|
+
tenantId: TENANT,
|
|
267
|
+
roles: ["Member"],
|
|
268
|
+
by: createSystemUser(TENANT),
|
|
269
|
+
});
|
|
270
|
+
const deleteResult = await membershipExecutor.delete(
|
|
271
|
+
{ id: created.id },
|
|
272
|
+
createSystemUser(TENANT),
|
|
273
|
+
createTenantDb(stack.db, TENANT, "system"),
|
|
274
|
+
);
|
|
275
|
+
if (!deleteResult.isSuccess) {
|
|
276
|
+
throw new Error(`test setup: co-member delete failed: ${deleteResult.error.code}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pins the test's premise: exactly 1 live membership row, so a live-count-
|
|
280
|
+
// only check would call this tenant single-user (and the fix must reject
|
|
281
|
+
// that call via history instead).
|
|
282
|
+
const liveMemberships = await asRawClient(stack.db).unsafe(
|
|
283
|
+
"SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
|
|
284
|
+
[TENANT],
|
|
285
|
+
);
|
|
286
|
+
expect(liveMemberships.length).toBe(1);
|
|
287
|
+
|
|
288
|
+
const result = await runForgetCleanup({
|
|
289
|
+
db: stack.db,
|
|
290
|
+
registry: stack.registry,
|
|
291
|
+
now: nowInstant(),
|
|
292
|
+
tenantModel: "single-user",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(result.errors).toHaveLength(0);
|
|
296
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
297
|
+
// Only 1 live membership remains (FORGET_USER) — the OLD live-only check
|
|
298
|
+
// would have called this single-user and wiped the co-member's row. The
|
|
299
|
+
// historical check must still resolve multi-user here.
|
|
300
|
+
expect(await rowCount()).toBe(1);
|
|
301
|
+
});
|
|
227
302
|
});
|
|
228
303
|
|
|
229
304
|
describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
|
|
2
2
|
export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
|
|
3
|
+
export {
|
|
4
|
+
denyIfTargetOutsideAdminTenant,
|
|
5
|
+
TARGET_USER_NOT_IN_ADMIN_TENANT,
|
|
6
|
+
} from "./lib/deny-if-target-outside-admin-tenant";
|
|
7
|
+
export { isSystemAdminActor } from "./lib/is-admin-actor";
|
|
3
8
|
// #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
|
|
4
9
|
// von read_users-Rebuilds (siehe lib-Doc).
|
|
5
10
|
export { backfillUserLifecycleEvents, updateUserLifecycle } from "./lib/update-user-lifecycle";
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
type KmsAdapter,
|
|
39
39
|
subjectIdToKey,
|
|
40
40
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
type DbRunner,
|
|
43
|
+
entityEventName,
|
|
44
|
+
nullBlindIndexesForSubject,
|
|
45
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
42
46
|
import {
|
|
43
47
|
EXT_USER_DATA,
|
|
44
48
|
EXT_USER_DATA_ORDER,
|
|
@@ -49,6 +53,7 @@ import {
|
|
|
49
53
|
type UserDataDeleteStrategy,
|
|
50
54
|
type UserDataStorageProvider,
|
|
51
55
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
56
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
52
57
|
import {
|
|
53
58
|
purgeSearchDocumentsForSubject,
|
|
54
59
|
type SearchAdapter,
|
|
@@ -554,7 +559,37 @@ async function resolveEffectiveTenantModel(
|
|
|
554
559
|
{ tenantId },
|
|
555
560
|
{ limit: 2 },
|
|
556
561
|
);
|
|
557
|
-
|
|
562
|
+
if (members.length !== 1) return "multi-user";
|
|
563
|
+
return (await everHadMultipleMembers(db, tenantId)) ? "multi-user" : "single-user";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// removeMemberWrite only deletes the tenant-membership PROJECTION row — the
|
|
567
|
+
// event history survives forever, and a departed member's own tenant-scoped
|
|
568
|
+
// data rows (contributor-style records with no per-user column) can
|
|
569
|
+
// outlive their membership (ent#346). A live count of 1 is therefore not
|
|
570
|
+
// enough: a tenant that ever had >1 distinct member must stay multi-user for
|
|
571
|
+
// the forget path even after everyone but one is removed, or the sole
|
|
572
|
+
// remaining member's forget request wipes the departed co-member's leftover
|
|
573
|
+
// rows. LIMIT chosen generously for a nominally single-user tenant; hitting
|
|
574
|
+
// it is itself a multi-user signal, so it also resolves to "multi-user".
|
|
575
|
+
const MAX_MEMBERSHIP_CREATED_EVENTS = 1000;
|
|
576
|
+
|
|
577
|
+
async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
|
|
578
|
+
const createdEvents = await selectMany<{ payload: Record<string, unknown> }>(
|
|
579
|
+
db,
|
|
580
|
+
eventsTable,
|
|
581
|
+
{
|
|
582
|
+
tenantId,
|
|
583
|
+
aggregateType: "tenant-membership",
|
|
584
|
+
type: entityEventName("tenant-membership", "created"),
|
|
585
|
+
},
|
|
586
|
+
{ limit: MAX_MEMBERSHIP_CREATED_EVENTS },
|
|
587
|
+
);
|
|
588
|
+
if (createdEvents.length >= MAX_MEMBERSHIP_CREATED_EVENTS) return true;
|
|
589
|
+
const distinctUserIds = new Set(
|
|
590
|
+
createdEvents.map((e) => e.payload["userId"]).filter((v): v is string => typeof v === "string"),
|
|
591
|
+
);
|
|
592
|
+
return distinctUserIds.size > 1;
|
|
558
593
|
}
|
|
559
594
|
|
|
560
595
|
// Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
|