@cosmicdrift/kumiko-bundled-features 0.201.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.201.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.201.0",
130
- "@cosmicdrift/kumiko-framework": "0.201.0",
131
- "@cosmicdrift/kumiko-headless": "0.201.0",
132
- "@cosmicdrift/kumiko-renderer": "0.201.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.201.0",
134
- "@cosmicdrift/kumiko-types": "0.201.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",
@@ -30,6 +30,18 @@ import { currentTotpCode } from "../totp";
30
30
  // account — every OTHER live session must be signed out (stolen-session
31
31
  // defense), but the session that performed the change must survive.
32
32
 
33
+ // sessionChecker's "live" outcome can now carry re-derived roles as
34
+ // `{status: "live", roles: [...]}` instead of the bare string — these tests
35
+ // only care about liveness, not the exact role set, so unwrap the status.
36
+ async function checkerStatus(
37
+ callbacks: ReturnType<typeof createSessionCallbacks>,
38
+ sid: string,
39
+ userId: string,
40
+ ): Promise<string> {
41
+ const result = await callbacks.sessionChecker(sid, userId);
42
+ return typeof result === "string" ? result : result.status;
43
+ }
44
+
33
45
  let stack: TestStack;
34
46
  let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
35
47
 
@@ -94,8 +106,8 @@ describe("session auto-revoke on MFA state changes", () => {
94
106
  user,
95
107
  );
96
108
 
97
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
98
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
109
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
110
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
99
111
  });
100
112
 
101
113
  test("disable revokes every OTHER session but keeps the caller's", async () => {
@@ -130,8 +142,8 @@ describe("session auto-revoke on MFA state changes", () => {
130
142
  caller,
131
143
  );
132
144
 
133
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
134
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
145
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
146
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
135
147
  });
136
148
 
137
149
  test("a failed disable attempt does NOT revoke any session", async () => {
@@ -163,7 +175,7 @@ describe("session auto-revoke on MFA state changes", () => {
163
175
  const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
164
176
  expectErrorIncludes(err, "invalid_totp_code");
165
177
 
166
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
167
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("live");
178
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
179
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("live");
168
180
  });
169
181
  });
@@ -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,
@@ -49,10 +49,12 @@ const stubNav: NavApi = {
49
49
  route: undefined,
50
50
  navigate: () => {},
51
51
  replace: () => {},
52
- hrefFor: (target) =>
53
- target.entityId !== undefined
52
+ hrefFor: (target) => {
53
+ if (!("screenId" in target)) return "";
54
+ return target.entityId !== undefined
54
55
  ? `/${target.screenId}/${target.entityId}`
55
- : `/${target.screenId}`,
56
+ : `/${target.screenId}`;
57
+ },
56
58
  searchParams: {},
57
59
  setSearchParams: () => {},
58
60
  };
@@ -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,
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
5
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ testTenantId,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import {
15
+ createLateBoundHolder,
16
+ createTestEnvelopeCipher,
17
+ updateRows,
18
+ } from "@cosmicdrift/kumiko-framework/testing";
19
+ import { AuthHandlers } from "../../auth-email-password/constants";
20
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigResolver } from "../../config/resolver";
23
+ import { configValuesTable } from "../../config/table";
24
+ import { TenantQueries } from "../../tenant/constants";
25
+ import { createTenantFeature } from "../../tenant/feature";
26
+ import { tenantInvitationEntity } from "../../tenant/invitation-table";
27
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
28
+ import { tenantEntity } from "../../tenant/schema/tenant";
29
+ import { createUserFeature } from "../../user/feature";
30
+ import { userEntity, userTable } from "../../user/schema/user";
31
+ import { createSessionsFeature } from "../feature";
32
+ import { userSessionEntity, userSessionTable } from "../schema/user-session";
33
+ import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
34
+ import { sessionCallbacksFromLateBound } from "../testing";
35
+ import { makeSessionHelpers } from "./test-helpers";
36
+
37
+ // Proves the core DoD of #2148: a role change written directly to
38
+ // tenantMembershipsTable takes effect on the very next request made with an
39
+ // ALREADY-ISSUED, still-live token — no re-login, no JWT expiry needed.
40
+ // Deliberately bypasses update-member-roles.write.ts (which force-revokes
41
+ // the session on a role change) so the effect under test isn't masked by
42
+ // that unrelated, already-correct revoke-on-change behavior.
43
+
44
+ let stack: TestStack;
45
+ let h: ReturnType<typeof makeSessionHelpers>;
46
+ let sessionCreator: SessionCreator;
47
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
48
+
49
+ const encryptionKey = randomBytes(32).toString("base64");
50
+
51
+ // Matches TestUsers.systemAdmin.tenantId — same rationale as
52
+ // membership-revoke.integration.test.ts.
53
+ const TENANT_A: TenantId = testTenantId(1);
54
+
55
+ beforeAll(async () => {
56
+ const encryption = createTestEnvelopeCipher(encryptionKey);
57
+ const resolver = createConfigResolver({ cipher: encryption });
58
+ const bound = sessionCallbacksFromLateBound(callbacks);
59
+
60
+ stack = await setupTestStack({
61
+ features: [
62
+ createConfigFeature(),
63
+ createUserFeature(),
64
+ createTenantFeature(),
65
+ createAuthEmailPasswordFeature(),
66
+ authFoundationFeature,
67
+ createSessionsFeature(),
68
+ ],
69
+ extraContext: { configResolver: resolver, configEncryption: encryption },
70
+ authConfig: {
71
+ ...bound.asAuthConfig(),
72
+ membershipQuery: "tenant:query:memberships",
73
+ loginHandler: AuthHandlers.login,
74
+ },
75
+ });
76
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
77
+ const creator = bound.asAuthConfig().sessionCreator;
78
+ if (!creator) throw new Error("sessionCreator missing from bound auth config");
79
+ sessionCreator = creator;
80
+ h = makeSessionHelpers(stack, TENANT_A, sessionCreator);
81
+
82
+ await unsafeCreateEntityTable(stack.db, userEntity);
83
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
84
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
85
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
86
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await stack.cleanup();
91
+ });
92
+
93
+ beforeEach(async () => {
94
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
95
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
96
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
97
+ });
98
+
99
+ describe("sessionChecker re-derives roles from the DB on every request", () => {
100
+ test("a DB-side membership role change unlocks a role-gated action for the SAME live token", async () => {
101
+ // 1. Log in with an initial tenant-membership role set that does NOT
102
+ // satisfy the admin gate below.
103
+ const { userId } = await h.seedUser("role-upgrade@example.com", "first-password", {
104
+ roles: ["User"],
105
+ });
106
+ const { token } = await h.login("role-upgrade@example.com", "first-password");
107
+
108
+ // 2. tenant:query:invitations is gated on access.admin
109
+ // (["TenantAdmin", "Admin", "SystemAdmin"]) — the freshly-logged-in
110
+ // "User" role does not satisfy it.
111
+ const before = await h.authedPost("/api/query", token, {
112
+ type: TenantQueries.invitations,
113
+ payload: {},
114
+ });
115
+ expect(before.status).toBe(403);
116
+
117
+ // 3. Elevate the role by writing DIRECTLY to tenantMembershipsTable —
118
+ // explicitly NOT via update-member-roles.write.ts, whose
119
+ // force-revoke-on-change would kill the very token this test needs
120
+ // to keep using.
121
+ await updateRows(
122
+ stack.db,
123
+ tenantMembershipsTable,
124
+ { roles: JSON.stringify(["Admin"]) },
125
+ { userId, tenantId: TENANT_A },
126
+ );
127
+
128
+ // 4. Same, still-unrevoked, still-unexpired token — the next request
129
+ // re-derives roles from the DB and now passes the gate.
130
+ const after = await h.authedPost("/api/query", token, {
131
+ type: TenantQueries.invitations,
132
+ payload: {},
133
+ });
134
+ expect(after.status).toBe(200);
135
+ expect(await after.json()).toEqual({ data: [] });
136
+ });
137
+ });
@@ -494,7 +494,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
494
494
 
495
495
  // Direct invariant: Bob's sid + Alice's userId → missing (not live/revoked).
496
496
  expect(await callbacks.get().sessionChecker(bobLogin.sid, alice.userId)).toBe("missing");
497
- expect(await callbacks.get().sessionChecker(bobLogin.sid, bob.userId)).toBe("live");
497
+ const bobResult = await callbacks.get().sessionChecker(bobLogin.sid, bob.userId);
498
+ const bobStatus = typeof bobResult === "string" ? bobResult : bobResult.status;
499
+ expect(bobStatus).toBe("live");
498
500
 
499
501
  // HTTP path: mint Alice's identity onto Bob's sid (stolen-sid scenario).
500
502
  const forged = await stack.jwt.sign({
@@ -1,5 +1,5 @@
1
1
  import type {
2
- AuthSessionStatus,
2
+ AuthSessionCheckResult,
3
3
  SessionChecker,
4
4
  SessionCreator,
5
5
  SessionMassRevoker,
@@ -11,12 +11,13 @@ export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
11
11
 
12
12
  import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
15
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { buildSessionRoles, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
17
- import { generateId } from "@cosmicdrift/kumiko-framework/utils";
17
+ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
18
18
  import { Temporal } from "temporal-polyfill";
19
19
  import { encryptForDirectWrite } from "../shared";
20
+ import { tenantMembershipsTable } from "../tenant";
20
21
  import { USER_STATUS, type UserStatus, userTable } from "../user";
21
22
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
22
23
  import { userSessionEntity, userSessionTable } from "./schema/user-session";
@@ -113,9 +114,10 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
113
114
  );
114
115
  },
115
116
 
116
- async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionStatus> {
117
+ async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
117
118
  const row = await fetchOne<{
118
119
  userId: string;
120
+ tenantId: TenantId;
119
121
  revokedAt: unknown;
120
122
  expiresAt: { epochMilliseconds: number };
121
123
  }>(db, userSessionTable, { id: sid });
@@ -137,14 +139,43 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
137
139
  // revocation is primary; never turn a user-row miss into a global
138
140
  // lockout. (+1 PK read on read_users per authenticated request.)
139
141
  //
140
- // Fail-open covers a THROW too, not just a null-miss: this read sits on
141
- // the hot path of every authenticated request, so a DB timeout / lock
142
- // contention / pool exhaustion here must not turn into a global lockout.
143
- const user = await fetchOne<{ status: UserStatus }>(db, userTable, {
144
- id: expectedUserId,
145
- }).catch(() => null);
146
- if (user && isPrincipalBlocked(user.status)) return "blocked";
147
- return "live";
142
+ // Fail-open covers a THROW *and* a null-miss, not just a throw: this
143
+ // read sits on the hot path of every authenticated request, and a
144
+ // missing row here means we have no DB-confirmed roles to derive from
145
+ // (e.g. a bootstrap/system actor with no persisted user row) — that is
146
+ // a different situation from tenantMembershipsTable below, where a
147
+ // missing row is a legitimate "no tenant roles" outcome. Both branches
148
+ // return the bare "live" string (no re-derived roles) the middleware
149
+ // falls back to the JWT's frozen roles claim.
150
+ let user: { status: UserStatus; roles: string | null } | undefined;
151
+ try {
152
+ user = await fetchOne<{ status: UserStatus; roles: string | null }>(db, userTable, {
153
+ id: expectedUserId,
154
+ });
155
+ } catch {
156
+ return "live";
157
+ }
158
+ if (!user) return "live";
159
+ if (isPrincipalBlocked(user.status)) return "blocked";
160
+
161
+ // Same fail-open reasoning as the userTable lookup above — a transient
162
+ // DB error on the membership read must not lock the user out. A
163
+ // missing row (not a throw) is a valid outcome: the user genuinely has
164
+ // no tenant-scoped roles for row.tenantId, so membershipRoles is [].
165
+ let membershipRoles: readonly string[];
166
+ try {
167
+ const membership = await fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
168
+ userId: expectedUserId,
169
+ tenantId: row.tenantId,
170
+ });
171
+ membershipRoles = membership ? parseRoles(membership.roles) : [];
172
+ } catch {
173
+ return "live";
174
+ }
175
+
176
+ const globalRoles = parseRoles(user.roles);
177
+ const roles = buildSessionRoles(globalRoles, membershipRoles);
178
+ return { status: "live", roles } as const;
148
179
  },
149
180
 
150
181
  async sessionMassRevoker(userId: string): Promise<number> {
@@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import * as bunDb from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { Temporal } from "temporal-polyfill";
5
+ import { tenantMembershipsTable } from "../tenant/membership-table";
5
6
  import { USER_STATUS, userTable } from "../user/schema/user";
6
7
  import { userSessionTable } from "./schema/user-session";
7
8
  import { createSessionCallbacks } from "./session-callbacks";
@@ -74,4 +75,77 @@ describe("sessionChecker fail-open on user-lookup throw", () => {
74
75
  spy.mockRestore();
75
76
  }
76
77
  });
78
+
79
+ test("membership lookup THROW → live (not 500)", async () => {
80
+ const db = {} as DbConnection;
81
+ const cbs = createSessionCallbacks({ db });
82
+ const sid = "00000000-0000-4000-8000-00000000sid3";
83
+ const userId = "00000000-0000-4000-8000-00000000usr3";
84
+ const tenantId = "00000000-0000-4000-8000-000000tenant";
85
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
86
+
87
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
88
+ if (table === userSessionTable) {
89
+ return {
90
+ userId,
91
+ tenantId,
92
+ revokedAt: null,
93
+ expiresAt: { epochMilliseconds: farFutureMs },
94
+ };
95
+ }
96
+ if (table === userTable) {
97
+ return { status: USER_STATUS.Active, roles: null };
98
+ }
99
+ if (table === tenantMembershipsTable) {
100
+ throw new Error("simulated pool exhaustion");
101
+ }
102
+ throw new Error("unexpected table in sessionChecker spy");
103
+ }) as FetchOne);
104
+
105
+ try {
106
+ expect(await cbs.sessionChecker(sid, userId)).toBe("live");
107
+ } finally {
108
+ spy.mockRestore();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("sessionChecker role re-derivation", () => {
114
+ test("live session composes global + tenant-membership roles fresh from the DB", async () => {
115
+ const db = {} as DbConnection;
116
+ const cbs = createSessionCallbacks({ db });
117
+ const sid = "00000000-0000-4000-8000-00000000sid4";
118
+ const userId = "00000000-0000-4000-8000-00000000usr4";
119
+ const tenantId = "00000000-0000-4000-8000-000000tenan2";
120
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
121
+
122
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
123
+ if (table === userSessionTable) {
124
+ return {
125
+ userId,
126
+ tenantId,
127
+ revokedAt: null,
128
+ expiresAt: { epochMilliseconds: farFutureMs },
129
+ };
130
+ }
131
+ if (table === userTable) {
132
+ return { status: USER_STATUS.Active, roles: JSON.stringify(["Support"]) };
133
+ }
134
+ if (table === tenantMembershipsTable) {
135
+ return { roles: JSON.stringify(["User"]) };
136
+ }
137
+ throw new Error("unexpected table in sessionChecker spy");
138
+ }) as FetchOne);
139
+
140
+ try {
141
+ const result = await cbs.sessionChecker(sid, userId);
142
+ if (typeof result === "string") {
143
+ throw new Error(`expected object result, got bare string "${result}"`);
144
+ }
145
+ expect(result.status).toBe("live");
146
+ expect([...result.roles].sort()).toEqual(["Support", "User"]);
147
+ } finally {
148
+ spy.mockRestore();
149
+ }
150
+ });
77
151
  });
@@ -39,6 +39,20 @@ export function setWebhookFetch(fn: typeof fetch): void {
39
39
  }
40
40
 
41
41
  export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
42
+ // SSRF guard at the primitive boundary: only http(s), and never follow
43
+ // redirects — a 3xx could point at an internal/metadata target and the
44
+ // spec carries secrets (auth) that would be forwarded there. A webhook
45
+ // destination that redirects now surfaces as a delivery error instead.
46
+ let url: URL;
47
+ try {
48
+ url = new URL(spec.url);
49
+ } catch {
50
+ return { ok: false, error: `invalid url "${spec.url}"` };
51
+ }
52
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
53
+ return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
54
+ }
55
+
42
56
  const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
43
57
  if (spec.auth) {
44
58
  const secret = secretResolver(spec.auth.secretRef);
@@ -55,6 +69,7 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
55
69
  const res = await fetchImpl(spec.url, {
56
70
  method: spec.method,
57
71
  headers,
72
+ redirect: "manual",
58
73
  body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
59
74
  });
60
75
  if (!res.ok) {
@@ -45,24 +45,25 @@ export const removeMemberWrite = defineWriteHandler({
45
45
  );
46
46
  }
47
47
 
48
+ // Revoke THIS tenant's sessions BEFORE the delete — a removed member must
49
+ // not keep a valid session for the window between the delete and a
50
+ // post-delete revoke. Best-effort cross-feature call: sessions may not be
51
+ // mounted (registry lookup, see above). If the delete then fails, the
52
+ // member is logged out but the membership is intact — safe direction.
53
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
54
+ if (revoker) {
55
+ await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
56
+ userId: event.payload.userId,
57
+ tenantId: event.payload.tenantId,
58
+ });
59
+ }
60
+
48
61
  const result = await executor.delete(
49
62
  { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
50
63
  event.user,
51
64
  db,
52
65
  );
53
66
 
54
- // Revoke only this tenant's sessions — a multi-tenant user stays logged
55
- // in to tenants they're still a member of. Best-effort cross-feature
56
- // call: sessions may not be mounted (registry lookup, see above).
57
- if (result.isSuccess) {
58
- const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
59
- if (revoker) {
60
- await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
61
- userId: event.payload.userId,
62
- tenantId: event.payload.tenantId,
63
- });
64
- }
65
- }
66
67
  return withResponseData(result, event.payload);
67
68
  },
68
69
  });
@@ -62,6 +62,19 @@ export const updateMemberRolesWrite = defineWriteHandler({
62
62
  // silent overwrite. Per-membership parallelism is rare; if it happens,
63
63
  // the client retries on the error.
64
64
  const row = existing as DbRow; // @cast-boundary generic-record
65
+ // A role change can be security-relevant (e.g. demoting an Admin) — the
66
+ // user must re-authenticate with the new roles, everywhere. Revoke BEFORE
67
+ // the update closes the window where the demoted user keeps a valid
68
+ // session until the revoke write lands. Best-effort cross-feature call:
69
+ // sessions may not be mounted (registry lookup instead of a hard
70
+ // requires, see above).
71
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
72
+ if (revoker) {
73
+ await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
74
+ userId: event.payload.userId,
75
+ });
76
+ }
77
+
65
78
  const result = await executor.update(
66
79
  {
67
80
  id: row["id"] as string, // @cast-boundary db-row
@@ -72,18 +85,6 @@ export const updateMemberRolesWrite = defineWriteHandler({
72
85
  db,
73
86
  );
74
87
 
75
- // A role change can be security-relevant (e.g. demoting an Admin) — the
76
- // user must re-authenticate with the new roles, everywhere, not just in
77
- // this tenant. Best-effort cross-feature call: sessions may not be
78
- // mounted (registry lookup instead of a hard requires, see above).
79
- if (result.isSuccess) {
80
- const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
81
- if (revoker) {
82
- await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
83
- userId: event.payload.userId,
84
- });
85
- }
86
- }
87
88
  return withResponseData(result, event.payload);
88
89
  },
89
90
  });
@@ -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,