@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.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.
Files changed (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import {
4
+ InternalError,
4
5
  NotFoundError,
5
6
  UnprocessableError,
6
7
  writeFailure,
@@ -24,7 +25,12 @@ export const retryWrite = defineWriteHandler({
24
25
  schema: z.object({ runId: z.uuid() }),
25
26
  access: { roles: ["SystemAdmin"] },
26
27
  handler: async (event, ctx) => {
27
- const db = ctx.db;
28
+ if (!ctx.systemDb) {
29
+ throw new InternalError({
30
+ message: "jobs:write:retry requires ctx.systemDb (feature must declare r.systemScope())",
31
+ });
32
+ }
33
+ const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
28
34
  // @cast-boundary engine-payload — JobRunner attached by app-boot via ctx-extension
29
35
  const jobRunner = ctx["jobRunner"] as JobRunner;
30
36
 
@@ -21,6 +21,7 @@ import {
21
21
  deleteRows,
22
22
  resetBlindIndexKeyForTests,
23
23
  resetPiiSubjectKmsForTests,
24
+ updateRows,
24
25
  } from "@cosmicdrift/kumiko-framework/testing";
25
26
  import { Temporal } from "temporal-polyfill";
26
27
  import { AuthHandlers } from "../../auth-email-password/constants";
@@ -36,8 +37,9 @@ import { userSessionEntity } from "../../sessions/schema/user-session";
36
37
  import { createTenantFeature } from "../../tenant";
37
38
  import { tenantMembershipsTable } from "../../tenant/membership-table";
38
39
  import { tenantEntity } from "../../tenant/schema/tenant";
40
+ import { USER_STATUS } from "../../user";
39
41
  import { createUserFeature } from "../../user/feature";
40
- import { userEntity } from "../../user/schema/user";
42
+ import { userEntity, userTable } from "../../user/schema/user";
41
43
  import { PatHandlers, PatQueries } from "../constants";
42
44
  import { createPersonalAccessTokensFeature } from "../feature";
43
45
  import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
@@ -209,6 +211,17 @@ describe("PAT auth", () => {
209
211
  const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
210
212
  expect(res.status).toBe(401);
211
213
  });
214
+
215
+ test("user status Deleted → 401 (blocked principal, same as an unknown token)", async () => {
216
+ const actor = await actorFor("deleted-status@example.com");
217
+ const token = await mintToken(actor);
218
+ const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
219
+ expect(ok.status).toBe(200);
220
+
221
+ await updateRows(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: actor.id });
222
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
223
+ expect(res.status).toBe(401);
224
+ });
212
225
  });
213
226
 
214
227
  describe("PAT with active KMS (#820): token name is userOwned PII", () => {
@@ -83,12 +83,15 @@ export function createPersonalAccessTokensFeature(
83
83
  };
84
84
 
85
85
  // Dormant custom-screen — the client maps PAT_SCREEN_ID to PatTokensScreen;
86
- // the app places it via r.nav in its logged-in settings area.
86
+ // the app places it via r.nav in its logged-in settings area. dormant:
87
+ // true skips createKumikoApp's missing-client-plugin boot diagnostic
88
+ // (#2025) for apps that don't nav this screen (#2034).
87
89
  r.screen({
88
90
  id: PAT_SCREEN_ID,
89
91
  type: "custom",
90
92
  renderer: { react: { __component: "PatTokensScreen" } },
91
93
  access: { openToAll: true },
94
+ dormant: true,
92
95
  });
93
96
  r.translations({ keys: PAT_FEATURE_I18N });
94
97
 
@@ -2,8 +2,10 @@ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { buildSessionRoles } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
+ // kumiko-lint-ignore cross-feature-import shared lifecycle-status gate with session auth
6
+ import { isPrincipalBlocked } from "../sessions";
5
7
  import { tenantMembershipsTable } from "../tenant";
6
- import { userTable } from "../user";
8
+ import { type UserStatus, userTable } from "../user";
7
9
 
8
10
  // Live role resolution for a (userId, tenantId), mirroring login.write.ts:
9
11
  // global roles (users.roles) ∪ tenant-membership roles (forbidden roles
@@ -11,7 +13,10 @@ import { userTable } from "../user";
11
13
  // Resolved fresh on every PAT request — a snapshot baked at mint
12
14
  // time would keep a since-revoked admin role for the token's whole (months-long)
13
15
  // life. Returns null when the user has no membership in that tenant: removed
14
- // from the tenant → the PAT stops authenticating there.
16
+ // from the tenant → the PAT stops authenticating there. Also null when the
17
+ // user's lifecycle status blocks them (fw security-welle2) — collapsed onto
18
+ // the same "no roles" outcome as missing-membership so a blocked user's PAT
19
+ // gets an identical 401 to an unknown token (no status oracle).
15
20
  export async function resolvePatRoles(
16
21
  db: DbConnection,
17
22
  userId: string,
@@ -23,7 +28,10 @@ export async function resolvePatRoles(
23
28
  });
24
29
  const membership = memberships[0];
25
30
  if (!membership) return null;
26
- const userRow = await fetchOne<{ roles: string | null }>(db, userTable, { id: userId });
31
+ const userRow = await fetchOne<{ roles: string | null; status: UserStatus }>(db, userTable, {
32
+ id: userId,
33
+ });
34
+ if (userRow && isPrincipalBlocked(userRow.status)) return null;
27
35
  const globalRoles = parseRoles(userRow?.roles ?? null);
28
36
  return buildSessionRoles(globalRoles, parseRoles(membership.roles));
29
37
  }
@@ -19,7 +19,7 @@ export const statusQuery = defineQueryHandler({
19
19
  handler: async (query, ctx) => {
20
20
  // One gate for both halves: required keys/secrets of provider-features
21
21
  // count only while their provider is the selected one (r.extensionSelector).
22
- const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user);
22
+ const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user, ctx.db);
23
23
  // skipAccessFilter: das Verdict muss ALLE required Keys zählen — der
24
24
  // Handler selbst ist TenantAdmin-gated, der Per-Key-Filter wäre hier
25
25
  // eine ready:true-Lüge für SystemAdmin-gated Keys (277/1).
@@ -27,6 +27,7 @@ export const statusQuery = defineQueryHandler({
27
27
  ctx,
28
28
  ReadinessQueries.status,
29
29
  query.user,
30
+ ctx.db,
30
31
  gate,
31
32
  { skipAccessFilter: true },
32
33
  );
@@ -0,0 +1,195 @@
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 { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ TestUsers,
11
+ testTenantId,
12
+ unsafeCreateEntityTable,
13
+ unsafePushTables,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import {
16
+ createLateBoundHolder,
17
+ createTestEnvelopeCipher,
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 { createTenantFeature } from "../../tenant";
25
+ import { TenantHandlers } from "../../tenant/constants";
26
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
27
+ import { tenantEntity } from "../../tenant/schema/tenant";
28
+ import { seedTenantMembership } from "../../tenant/seeding";
29
+ import { UserHandlers } from "../../user";
30
+ import { createUserFeature } from "../../user/feature";
31
+ import { userEntity, userTable } from "../../user/schema/user";
32
+ import { createSessionsFeature } from "../feature";
33
+ import { userSessionEntity, userSessionTable } from "../schema/user-session";
34
+ import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
35
+ import { sessionCallbacksFromLateBound, withMintedSession } from "../testing";
36
+ import { makeSessionHelpers } from "./test-helpers";
37
+
38
+ // Proves the two tenant-membership handlers (update-member-roles,
39
+ // remove-member) reach into the sessions feature's cross-tenant / tenant-
40
+ // scoped revoke, cutting an already-issued JWT instead of leaving it live
41
+ // until the 30-day TTL. See remove-member.write.ts / update-member-roles.write.ts.
42
+
43
+ let stack: TestStack;
44
+ let h: ReturnType<typeof makeSessionHelpers>;
45
+ let sessionCreator: SessionCreator;
46
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
47
+
48
+ // request-helper's authHeader() auto-mints + CACHES a sid per (userId,
49
+ // tenantId) the first time it sees a sid-less actor — a cached sid from an
50
+ // earlier test survives that test's own userSessionTable truncation in
51
+ // beforeEach and turns into a dangling row lookup ("session_invalid —
52
+ // missing") on the next test. Minting a fresh admin session per test
53
+ // sidesteps the cache entirely (the actor already carries a live sid).
54
+ async function mintSystemAdmin(): Promise<SessionUser> {
55
+ return withMintedSession(sessionCreator, TestUsers.systemAdmin);
56
+ }
57
+
58
+ const encryptionKey = randomBytes(32).toString("base64");
59
+
60
+ // Matches TestUsers.systemAdmin.tenantId — seed + write events land on the
61
+ // same stream (see password-auto-revoke.integration.test.ts for why).
62
+ const TENANT_A: TenantId = testTenantId(1);
63
+ const TENANT_B: TenantId = testTenantId(2);
64
+
65
+ beforeAll(async () => {
66
+ const encryption = createTestEnvelopeCipher(encryptionKey);
67
+ const resolver = createConfigResolver({ cipher: encryption });
68
+ const bound = sessionCallbacksFromLateBound(callbacks);
69
+
70
+ stack = await setupTestStack({
71
+ features: [
72
+ createConfigFeature(),
73
+ createUserFeature(),
74
+ createTenantFeature(),
75
+ createAuthEmailPasswordFeature(),
76
+ authFoundationFeature,
77
+ createSessionsFeature(),
78
+ ],
79
+ extraContext: { configResolver: resolver, configEncryption: encryption },
80
+ authConfig: {
81
+ ...bound.asAuthConfig(),
82
+ membershipQuery: "tenant:query:memberships",
83
+ loginHandler: AuthHandlers.login,
84
+ },
85
+ });
86
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
87
+ // asAuthConfig() always sets sessionCreator (see sessionCallbacksFromLateBound) —
88
+ // the Pick<AuthRoutesConfig, ...> type just carries it as optional.
89
+ const creator = bound.asAuthConfig().sessionCreator;
90
+ if (!creator) throw new Error("sessionCreator missing from bound auth config");
91
+ sessionCreator = creator;
92
+ h = makeSessionHelpers(stack, TENANT_A, sessionCreator);
93
+
94
+ await unsafeCreateEntityTable(stack.db, userEntity);
95
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
96
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
97
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await stack.cleanup();
102
+ });
103
+
104
+ beforeEach(async () => {
105
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
106
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
107
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
108
+ });
109
+
110
+ describe("updateMemberRoles revokes every live session", () => {
111
+ test("a role change invalidates the member's current JWT", async () => {
112
+ const { userId } = await h.seedUser("role-change@example.com", "first-password");
113
+ const { token } = await h.login("role-change@example.com", "first-password");
114
+
115
+ expect(
116
+ (await h.authedPost("/api/query", token, { type: "user:query:user:me", payload: {} })).status,
117
+ ).toBe(200);
118
+
119
+ await stack.http.writeOk(
120
+ TenantHandlers.updateMemberRoles,
121
+ { userId, tenantId: TENANT_A, roles: ["Admin"] },
122
+ await mintSystemAdmin(),
123
+ );
124
+
125
+ expect(
126
+ (await h.authedPost("/api/query", token, { type: "user:query:user:me", payload: {} })).status,
127
+ ).toBe(401);
128
+ });
129
+ });
130
+
131
+ describe("removeMember revokes only the member's sessions in that tenant", () => {
132
+ test("multi-tenant user keeps their session in the tenant they're still a member of", async () => {
133
+ const { userId } = await h.seedUser("multi-tenant@example.com", "first-password");
134
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["User"] });
135
+
136
+ // Two separate logins, pinned to different tenants via lastActiveTenantId
137
+ // — mirrors auth.integration.test.ts scenario 7 (multi-membership
138
+ // tenant resolution).
139
+ await stack.http.writeOk(
140
+ UserHandlers.update,
141
+ { id: userId, changes: { lastActiveTenantId: TENANT_A }, version: 1 },
142
+ await mintSystemAdmin(),
143
+ );
144
+ const sessionA = await h.login("multi-tenant@example.com", "first-password");
145
+
146
+ await stack.http.writeOk(
147
+ UserHandlers.update,
148
+ { id: userId, changes: { lastActiveTenantId: TENANT_B }, version: 2 },
149
+ await mintSystemAdmin(),
150
+ );
151
+ const sessionB = await h.login("multi-tenant@example.com", "first-password");
152
+
153
+ expect(
154
+ (
155
+ await h.authedPost("/api/query", sessionA.token, {
156
+ type: "user:query:user:me",
157
+ payload: {},
158
+ })
159
+ ).status,
160
+ ).toBe(200);
161
+ expect(
162
+ (
163
+ await h.authedPost("/api/query", sessionB.token, {
164
+ type: "user:query:user:me",
165
+ payload: {},
166
+ })
167
+ ).status,
168
+ ).toBe(200);
169
+
170
+ await stack.http.writeOk(
171
+ TenantHandlers.removeMember,
172
+ { userId, tenantId: TENANT_A },
173
+ await mintSystemAdmin(),
174
+ );
175
+
176
+ // Tenant A session is dead...
177
+ expect(
178
+ (
179
+ await h.authedPost("/api/query", sessionA.token, {
180
+ type: "user:query:user:me",
181
+ payload: {},
182
+ })
183
+ ).status,
184
+ ).toBe(401);
185
+ // ...but tenant B session is untouched — the user is still a member there.
186
+ expect(
187
+ (
188
+ await h.authedPost("/api/query", sessionB.token, {
189
+ type: "user:query:user:me",
190
+ payload: {},
191
+ })
192
+ ).status,
193
+ ).toBe(200);
194
+ });
195
+ });
@@ -27,8 +27,8 @@ import {
27
27
  import { SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema } from "./session-revoked-event";
28
28
 
29
29
  export type SessionsFeatureOptions = {
30
- // A successful update on the `user` entity that changes the `passwordHash`
31
- // column triggers a mass-revoke of every live session for that user.
30
+ // A successful update on the `user` entity that changes `passwordHash`
31
+ // triggers a mass-revoke of every live session for that user.
32
32
  // Industry-standard "password-change signs you out everywhere" flow,
33
33
  // including the session that did the change itself — the client has
34
34
  // to re-login after a password change.
@@ -173,14 +173,22 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
173
173
  // Cross-feature entity hook on "user". `r.entityHook` (NOT `r.hook`) is
174
174
  // the supported cross-feature path: entity-keyed, not prefixed by the
175
175
  // registering feature. Fires after every successful write on any
176
- // user-entity handler; we only act when passwordHash is part of the
177
- // changes-delta the handler was given.
176
+ // user-entity handler; we only act when one of REVOKE_TRIGGERING_FIELDS
177
+ // is part of the changes-delta the handler was given.
178
178
  //
179
- // Checking `changes["passwordHash"] !== undefined` is cheaper and more
180
- // correct than diffing data vs previous — "undefined in changes" means
181
- // "the handler didn't touch this column", which is exactly the signal
182
- // we want to skip on. Works for both direct user:update calls and any
179
+ // Checking `changes[field] !== undefined` is cheaper and more correct
180
+ // than diffing data vs previous — "undefined in changes" means "the
181
+ // handler didn't touch this column", which is exactly the signal we
182
+ // want to skip on. Works for both direct user:update calls and any
183
183
  // other handler that happens to write the column.
184
+ //
185
+ // Only `passwordHash` here: `status` changes go through
186
+ // updateUserLifecycle(), which writes via the event-store executor
187
+ // directly and never runs this postSave hook. restrict-account.write.ts
188
+ // already revokes sessions explicitly for that transition, and
189
+ // sessionChecker blocks a status-locked principal on its next request
190
+ // regardless.
191
+ const REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
184
192
  let autoRevoke = options?.autoRevokeOnPasswordChange;
185
193
  r.hook("postSave", { allOf: "user" }, async (ctx) => {
186
194
  // skip: nothing bound — stateless-JWT deployments without a runtime
@@ -191,8 +199,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
191
199
  // otherwise — every registration would do a mass-revoke roundtrip
192
200
  // for a user who literally has no rows in user_sessions.
193
201
  if (ctx.isNew) return;
194
- // skip: handler didn't touch passwordHash, nothing to revoke
195
- if (ctx.changes["passwordHash"] === undefined) return;
202
+ // skip: handler didn't touch any revoke-triggering field
203
+ if (!REVOKE_TRIGGERING_FIELDS.some((field) => ctx.changes[field] !== undefined)) return;
196
204
  await autoRevoke(String(ctx.id));
197
205
  });
198
206
 
@@ -21,14 +21,17 @@ import {
21
21
  // not the end-user themselves.
22
22
  //
23
23
  // Tenant-scope: the userSession schema persists tenantId per row (a user
24
- // can have sessions in multiple tenants). We revoke cross-tenant because
25
- // "account restriction" is a global user-level statement (the forget path
26
- // is global too, see the User-Entity special doc). The UPDATE filters
27
- // only on userId.
24
+ // can have sessions in multiple tenants). Omitting `tenantId` revokes
25
+ // cross-tenant because "account restriction" is a global user-level
26
+ // statement (the forget path is global too, see the User-Entity special
27
+ // doc). Passing `tenantId` narrows the UPDATE to that tenant's rows only —
28
+ // used by tenant-membership removal, where a multi-tenant user must stay
29
+ // logged in to tenants they're still a member of.
28
30
  export const revokeAllForUserWrite = defineWriteHandler({
29
31
  name: "user-session:revoke-all-for-user",
30
32
  schema: z.object({
31
33
  userId: z.string().min(1),
34
+ tenantId: z.string().min(1).optional(),
32
35
  }),
33
36
  access: { roles: access.privileged },
34
37
  handler: async (event, ctx) => {
@@ -36,7 +39,11 @@ export const revokeAllForUserWrite = defineWriteHandler({
36
39
  ctx.db.raw,
37
40
  userSessionTable,
38
41
  { revokedAt: Temporal.Now.instant() },
39
- { userId: event.payload.userId, revokedAt: null },
42
+ {
43
+ userId: event.payload.userId,
44
+ revokedAt: null,
45
+ ...(event.payload.tenantId !== undefined && { tenantId: event.payload.tenantId }),
46
+ },
40
47
  );
41
48
 
42
49
  // Lightweight append alongside the direct-write above (#1559) — see
@@ -14,7 +14,7 @@ export type {
14
14
  SessionCallbacksOptions,
15
15
  SessionMassRevoker,
16
16
  } from "./session-callbacks";
17
- export { createSessionCallbacks } from "./session-callbacks";
17
+ export { createSessionCallbacks, isPrincipalBlocked } from "./session-callbacks";
18
18
  export type { SessionRevokedPayload } from "./session-revoked-event";
19
19
  export {
20
20
  SESSION_REVOKED_AGGREGATE_TYPE,
@@ -34,6 +34,12 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
34
34
  USER_STATUS.Deleted,
35
35
  ]);
36
36
 
37
+ // Shared with personal-access-tokens' resolver — a PAT belonging to a
38
+ // locked-out principal must be refused the same way a live session is.
39
+ export function isPrincipalBlocked(status: UserStatus): boolean {
40
+ return BLOCKED_STATUSES.has(status);
41
+ }
42
+
37
43
  // Why the callbacks live at the raw-DB level rather than going through the
38
44
  // dispatcher: session-create/revoke/check run on the hot path of every
39
45
  // login and every request. The (createdAt/revokedAt/ip/userAgent) columns
@@ -137,7 +143,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
137
143
  const user = await fetchOne<{ status: UserStatus }>(db, userTable, {
138
144
  id: expectedUserId,
139
145
  }).catch(() => null);
140
- if (user && BLOCKED_STATUSES.has(user.status)) return "blocked";
146
+ if (user && isPrincipalBlocked(user.status)) return "blocked";
141
147
  return "live";
142
148
  },
143
149
 
@@ -0,0 +1,83 @@
1
+ // Security regression: tenant:write:update is systemScope() (ctx.db is not
2
+ // tenant-scoped), so without a self-check a caller with the tenant-scoped
3
+ // "Admin" role could pass a foreign tenantId as payload.id and update another
4
+ // tenant's row. Real HTTP via setupTestStack — no mocks.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
+ import {
9
+ setupTestStack,
10
+ type TestStack,
11
+ TestUsers,
12
+ unsafeCreateEntityTable,
13
+ unsafePushTables,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
16
+ import { createConfigFeature } from "../../config";
17
+ import { createConfigResolver } from "../../config/resolver";
18
+ import { configValuesTable } from "../../config/table";
19
+ import { TenantHandlers } from "../constants";
20
+ import { createTenantFeature } from "../feature";
21
+ import { tenantEntity } from "../schema/tenant";
22
+
23
+ let stack: TestStack;
24
+
25
+ beforeAll(async () => {
26
+ stack = await setupTestStack({
27
+ features: [createConfigFeature(), createTenantFeature()],
28
+ extraContext: { configResolver: createConfigResolver() },
29
+ });
30
+
31
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
32
+ await unsafePushTables(stack.db, { configValuesTable });
33
+ await createEventsTable(stack.db);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await stack.cleanup();
38
+ });
39
+
40
+ describe("tenant:write:update cross-tenant self-check", () => {
41
+ test("Admin can update its own tenant", async () => {
42
+ await stack.http.writeOk(
43
+ TenantHandlers.create,
44
+ { id: TestUsers.admin.tenantId, key: "own-tenant", name: "Own Tenant" },
45
+ TestUsers.systemAdmin,
46
+ );
47
+
48
+ const data = await stack.http.writeOk(
49
+ TenantHandlers.update,
50
+ { id: TestUsers.admin.tenantId, changes: { name: "Own Tenant Renamed" }, version: 1 },
51
+ TestUsers.admin,
52
+ );
53
+ expect((data!["data"] as Record<string, unknown>)["name"]).toBe("Own Tenant Renamed");
54
+ });
55
+
56
+ test("Admin cannot update a different tenant", async () => {
57
+ await stack.http.writeOk(
58
+ TenantHandlers.create,
59
+ { id: TestUsers.otherTenant.tenantId, key: "victim-tenant", name: "Victim Tenant" },
60
+ TestUsers.systemAdmin,
61
+ );
62
+
63
+ const err = await stack.http.writeErr(
64
+ TenantHandlers.update,
65
+ { id: TestUsers.otherTenant.tenantId, changes: { name: "Pwned" }, version: 1 },
66
+ TestUsers.admin,
67
+ );
68
+ expectErrorIncludes(err, "tenant_not_found");
69
+ });
70
+
71
+ test("SystemAdmin can still update any tenant", async () => {
72
+ const data = await stack.http.writeOk(
73
+ TenantHandlers.update,
74
+ {
75
+ id: TestUsers.otherTenant.tenantId,
76
+ changes: { name: "Renamed By SystemAdmin" },
77
+ version: 1,
78
+ },
79
+ TestUsers.systemAdmin,
80
+ );
81
+ expect((data!["data"] as Record<string, unknown>)["name"]).toBe("Renamed By SystemAdmin");
82
+ });
83
+ });
@@ -1,6 +1,10 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framework/db";
3
- import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ createSystemUser,
5
+ defineWriteHandler,
6
+ withResponseData,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
4
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
9
  import { z } from "zod";
6
10
  import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
@@ -9,6 +13,11 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
9
13
  entityName: "tenant-membership",
10
14
  });
11
15
 
16
+ // Literal QN, not an import off the sessions feature — tenant is
17
+ // foundational and must boot without sessions mounted (no r.requires/
18
+ // r.usesApi here, unlike user-data-rights:restrict-account's hard dep).
19
+ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user";
20
+
12
21
  export const removeMemberWrite = defineWriteHandler({
13
22
  name: "removeMember",
14
23
  schema: z.object({ userId: z.string(), tenantId: z.string() }),
@@ -33,6 +42,19 @@ export const removeMemberWrite = defineWriteHandler({
33
42
  event.user,
34
43
  db,
35
44
  );
45
+
46
+ // Revoke only this tenant's sessions — a multi-tenant user stays logged
47
+ // in to tenants they're still a member of. Best-effort cross-feature
48
+ // call: sessions may not be mounted (registry lookup, see above).
49
+ if (result.isSuccess) {
50
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
51
+ if (revoker) {
52
+ await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
53
+ userId: event.payload.userId,
54
+ tenantId: event.payload.tenantId,
55
+ });
56
+ }
57
+ }
36
58
  return withResponseData(result, event.payload);
37
59
  },
38
60
  });
@@ -1,6 +1,10 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framework/db";
3
- import { defineWriteHandler, withResponseData } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ createSystemUser,
5
+ defineWriteHandler,
6
+ withResponseData,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
4
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
9
  import { z } from "zod";
6
10
  import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles";
@@ -10,6 +14,11 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
10
14
  entityName: "tenant-membership",
11
15
  });
12
16
 
17
+ // Literal QN, not an import off the sessions feature — tenant is
18
+ // foundational and must boot without sessions mounted (no r.requires/
19
+ // r.usesApi here, unlike user-data-rights:restrict-account's hard dep).
20
+ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user";
21
+
13
22
  export const updateMemberRolesWrite = defineWriteHandler({
14
23
  name: "updateMemberRoles",
15
24
  schema: z.object({
@@ -54,6 +63,19 @@ export const updateMemberRolesWrite = defineWriteHandler({
54
63
  event.user,
55
64
  db,
56
65
  );
66
+
67
+ // A role change can be security-relevant (e.g. demoting an Admin) — the
68
+ // user must re-authenticate with the new roles, everywhere, not just in
69
+ // this tenant. Best-effort cross-feature call: sessions may not be
70
+ // mounted (registry lookup instead of a hard requires, see above).
71
+ if (result.isSuccess) {
72
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
73
+ if (revoker) {
74
+ await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
75
+ userId: event.payload.userId,
76
+ });
77
+ }
78
+ }
57
79
  return withResponseData(result, event.payload);
58
80
  },
59
81
  });
@@ -1,5 +1,11 @@
1
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
1
+ import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ AccessDeniedError,
5
+ InternalError,
6
+ NotFoundError,
7
+ writeFailure,
8
+ } from "@cosmicdrift/kumiko-framework/errors";
3
9
  import { z } from "zod";
4
10
  import { tenantEntity, tenantTable } from "../schema/tenant";
5
11
 
@@ -16,5 +22,32 @@ export const updateWrite = defineWriteHandler({
16
22
  changes: z.object({ name: z.string().min(1).max(200).optional() }),
17
23
  }),
18
24
  access: { roles: ["Admin", "SystemAdmin"] },
19
- handler: async (event, ctx) => crud.update(event.payload, event.user, ctx.db), // @wrapper-known semantic-alias
25
+ handler: async (event, ctx) => {
26
+ if (!ctx.systemDb) {
27
+ throw new InternalError({
28
+ message:
29
+ "tenant:write:update requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
30
+ });
31
+ }
32
+
33
+ // "Admin" is tenant-scoped and must not touch another tenant's row —
34
+ // SystemAdmin is the platform-wide role and stays cross-tenant by design.
35
+ if (event.user.roles.includes("SystemAdmin")) {
36
+ const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin is platform-wide by design");
37
+ return crud.update(event.payload, event.user, db);
38
+ }
39
+
40
+ let db: TenantDb;
41
+ try {
42
+ db = ctx.systemDb.assertTenantMatch(event.payload.id);
43
+ } catch (err) {
44
+ // Kept as tenant_not_found (not the underlying access_denied) so a
45
+ // cross-tenant Admin can't use the error to enumerate tenant existence.
46
+ if (err instanceof AccessDeniedError) {
47
+ return writeFailure(new NotFoundError("tenant", event.payload.id));
48
+ }
49
+ throw err;
50
+ }
51
+ return crud.update(event.payload, event.user, db);
52
+ },
20
53
  });