@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.202.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.202.0",
130
- "@cosmicdrift/kumiko-framework": "0.202.0",
131
- "@cosmicdrift/kumiko-headless": "0.202.0",
132
- "@cosmicdrift/kumiko-renderer": "0.202.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.202.0",
134
- "@cosmicdrift/kumiko-types": "0.202.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-Index-Sweep (#818): bidx-Spalten des erased Subjects sofort
74
- // nullen sonst bliebe der deterministische HMAC bis zum nächsten
75
- // Rebuild equality-matchbar. Bewusst ctx.db.raw: der Ciphertext-Prefix
76
- // adressiert das Subject tenant-übergreifend.
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 { DbConnection } from "@cosmicdrift/kumiko-framework/db";
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: DbConnection, userId: string): Promise<number> {
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,