@cosmicdrift/kumiko-bundled-features 0.202.0 → 0.203.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.203.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.203.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.203.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.203.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.203.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.203.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.203.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// - Member role → 403 (DPO/SystemAdmin only)
|
|
9
9
|
|
|
10
10
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { fetchOne, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
12
|
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
13
13
|
import {
|
|
14
14
|
buildEntityTable,
|
|
@@ -28,8 +28,21 @@ import {
|
|
|
28
28
|
TestUsers,
|
|
29
29
|
testTenantId,
|
|
30
30
|
unsafeCreateEntityTable,
|
|
31
|
+
unsafePushTables,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
33
|
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
34
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
35
|
+
import { Temporal } from "temporal-polyfill";
|
|
36
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
37
|
+
import { createConfigFeature } from "../../config";
|
|
38
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens/feature";
|
|
39
|
+
import { apiTokenEntity, apiTokenTable } from "../../personal-access-tokens/schema/api-token";
|
|
40
|
+
import { createTenantFeature } from "../../tenant";
|
|
41
|
+
import { tenantInvitationEntity } from "../../tenant/invitation-table";
|
|
42
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
43
|
+
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
44
|
+
import { createUserFeature } from "../../user/feature";
|
|
45
|
+
import { seedUser } from "../../user/seeding";
|
|
33
46
|
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
34
47
|
import { createCryptoShreddingFeature } from "../feature";
|
|
35
48
|
|
|
@@ -283,3 +296,86 @@ describe("crypto-shredding :: forget-subject purges the derived search index (#1
|
|
|
283
296
|
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
284
297
|
});
|
|
285
298
|
});
|
|
299
|
+
|
|
300
|
+
// The P2 audit finding: forget-subject shredded the DEK but left status +
|
|
301
|
+
// PATs untouched — a forgotten user's credentials stayed live (PAT resolver
|
|
302
|
+
// checks revokedAt/expiresAt, never user.status). Own stack with user +
|
|
303
|
+
// personal-access-tokens mounted (the handler's user-lifecycle + PAT-revoke
|
|
304
|
+
// branches guard on those features).
|
|
305
|
+
describe("crypto-shredding :: forget-subject closes the login door (user feature mounted)", () => {
|
|
306
|
+
let stack: TestStack;
|
|
307
|
+
let kms: InMemoryKmsAdapter;
|
|
308
|
+
const TENANT_B = testTenantId(3);
|
|
309
|
+
|
|
310
|
+
beforeAll(async () => {
|
|
311
|
+
stack = await setupTestStack({
|
|
312
|
+
features: [
|
|
313
|
+
createCryptoShreddingFeature(),
|
|
314
|
+
createUserFeature(),
|
|
315
|
+
createTenantFeature(),
|
|
316
|
+
createConfigFeature(),
|
|
317
|
+
authFoundationFeature,
|
|
318
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
322
|
+
await unsafeCreateEntityTable(stack.db, apiTokenEntity);
|
|
323
|
+
// tenant feature's only lookupable entity — the blind-index sweep
|
|
324
|
+
// touches it; without the table the handler 500s on `read_tenant_invitations`.
|
|
325
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
326
|
+
await unsafePushTables(stack.db, { tenantMembershipsTable });
|
|
327
|
+
await createEventsTable(stack.db);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
afterAll(async () => {
|
|
331
|
+
await stack.cleanup();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
beforeEach(async () => {
|
|
335
|
+
await resetTestTables(stack.db, [
|
|
336
|
+
userTable,
|
|
337
|
+
apiTokenTable,
|
|
338
|
+
tenantMembershipsTable,
|
|
339
|
+
eventsTable,
|
|
340
|
+
]);
|
|
341
|
+
kms = new InMemoryKmsAdapter();
|
|
342
|
+
configurePiiSubjectKms(kms);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
afterEach(() => {
|
|
346
|
+
resetPiiSubjectKmsForTests();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("user forget flips status to Deleted and revokes existing PATs", async () => {
|
|
350
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
351
|
+
email: "forgotten@example.com",
|
|
352
|
+
displayName: "Forgotten User",
|
|
353
|
+
emailVerified: true,
|
|
354
|
+
});
|
|
355
|
+
// No explicit createKey: seedUser's PII-encrypt (email) already created
|
|
356
|
+
// the subject key implicitly via getOrCreateDek.
|
|
357
|
+
await insertOne(stack.db, apiTokenTable, {
|
|
358
|
+
id: generateId(),
|
|
359
|
+
userId,
|
|
360
|
+
tenantId: TENANT_B,
|
|
361
|
+
name: "legacy-pat",
|
|
362
|
+
tokenHash: "a".repeat(64),
|
|
363
|
+
prefix: "ktest",
|
|
364
|
+
scopes: "[]",
|
|
365
|
+
createdAt: Temporal.Now.instant(),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await stack.http.writeOk(
|
|
369
|
+
FORGET,
|
|
370
|
+
{ subject: { kind: "user", userId }, reason: REASON },
|
|
371
|
+
dpoUser,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
|
|
375
|
+
expect(userRow?.["status"]).toBe(USER_STATUS.Deleted);
|
|
376
|
+
|
|
377
|
+
const tokens = await selectMany(stack.db, apiTokenTable, { userId });
|
|
378
|
+
expect(tokens).toHaveLength(1);
|
|
379
|
+
expect(tokens[0]?.["revokedAt"]).not.toBeNull();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -10,6 +10,9 @@ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework
|
|
|
10
10
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
11
11
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
+
import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
|
|
14
|
+
import { USER_STATUS } from "../../user";
|
|
15
|
+
import { updateUserLifecycle } from "../../user-data-rights";
|
|
13
16
|
import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
14
17
|
|
|
15
18
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
@@ -70,10 +73,10 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
70
73
|
eraseReason: event.payload.reason,
|
|
71
74
|
});
|
|
72
75
|
|
|
73
|
-
// Blind-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
76
|
+
// Blind-index sweep (#818): null the erased subject's bidx columns now —
|
|
77
|
+
// otherwise the deterministic HMAC stays equality-matchable until the next
|
|
78
|
+
// rebuild. Deliberately ctx.db.raw: the ciphertext prefix addresses the
|
|
79
|
+
// subject across tenants.
|
|
77
80
|
await nullBlindIndexesForSubject(ctx.db.raw, ctx.registry.features, subjectKey);
|
|
78
81
|
|
|
79
82
|
// Derived search index still holds plaintext (#1610) — purge next to the
|
|
@@ -88,6 +91,28 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
88
91
|
);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
95
|
+
// ciphertext unreadable, but status + PATs are standalone credentials —
|
|
96
|
+
// the PAT resolver only checks revokedAt/expiresAt, NOT user.status
|
|
97
|
+
// (resolver.ts). Without this block a forgotten user with a live PAT stays
|
|
98
|
+
// callable. Mirror of the automated Art.-17 path (userDeleteHook:
|
|
99
|
+
// status=Deleted; apiTokenDeleteHook: revoke):
|
|
100
|
+
// - user.updated as lifecycle event (updateUserLifecycle), so a
|
|
101
|
+
// read_users rebuild doesn't wipe the flip (#494)
|
|
102
|
+
// - sessions don't need active revocation — session-callbacks
|
|
103
|
+
// re-validate user.status on every request (isPrincipalBlocked
|
|
104
|
+
// blocks Deleted).
|
|
105
|
+
// Both idempotent (status set / revokedAt IS NULL filter), retry after
|
|
106
|
+
// crash recovery is safe. User-feature guard: without the user feature
|
|
107
|
+
// read_users doesn't exist (crypto-only stack). Tenant subjects have no
|
|
108
|
+
// credentials.
|
|
109
|
+
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
110
|
+
await updateUserLifecycle(ctx.db.raw, raw.userId, { status: USER_STATUS.Deleted });
|
|
111
|
+
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
112
|
+
await revokeAllPatTokensForUser(ctx.db.raw, raw.userId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
await ctx.unsafeAppendEvent({
|
|
92
117
|
aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
|
|
93
118
|
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { Temporal } from "temporal-polyfill";
|
|
4
4
|
import { apiTokenTable } from "./schema/api-token";
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@ import { apiTokenTable } from "./schema/api-token";
|
|
|
7
7
|
// level security events, not scoped to one tenant. Mirrors sessions'
|
|
8
8
|
// sessionMassRevoker (session-callbacks.ts) which passes the boot-time
|
|
9
9
|
// DbConnection directly — not ctx.db, which would be tenant-scoped in a hook.
|
|
10
|
-
export async function revokeAllPatTokensForUser(db:
|
|
10
|
+
export async function revokeAllPatTokensForUser(db: DbRunner, userId: string): Promise<number> {
|
|
11
11
|
const updated = await updateMany<{ id: string }>(
|
|
12
12
|
db,
|
|
13
13
|
apiTokenTable,
|
|
@@ -2,7 +2,7 @@ export { createUserDataRightsFeature, type UserDataRightsOptions } from "./featu
|
|
|
2
2
|
export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
|
|
3
3
|
// #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
|
|
4
4
|
// von read_users-Rebuilds (siehe lib-Doc).
|
|
5
|
-
export { backfillUserLifecycleEvents } from "./lib/update-user-lifecycle";
|
|
5
|
+
export { backfillUserLifecycleEvents, updateUserLifecycle } from "./lib/update-user-lifecycle";
|
|
6
6
|
export type {
|
|
7
7
|
SendExportFailedEmailFn,
|
|
8
8
|
SendExportReadyEmailFn,
|