@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- 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
|
-
|
|
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, {
|
|
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
|
+
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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
|
|
31
|
-
//
|
|
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
|
|
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[
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
|
195
|
-
if (ctx.changes[
|
|
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).
|
|
25
|
-
// "account restriction" is a global user-level
|
|
26
|
-
// is global too, see the User-Entity special
|
|
27
|
-
// only
|
|
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
|
-
{
|
|
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
|
package/src/sessions/index.ts
CHANGED
|
@@ -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 &&
|
|
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 {
|
|
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 {
|
|
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) =>
|
|
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
|
});
|