@cosmicdrift/kumiko-bundled-features 0.264.1 → 0.266.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 +9 -9
- package/src/auth-email-password/__tests__/active-membership.integration.test.ts +255 -0
- package/src/auth-email-password/handlers/login.write.ts +32 -12
- package/src/auth-mfa/__tests__/verify.integration.test.ts +41 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +5 -8
- package/src/auth-mfa/handlers/verify.write.ts +6 -11
- package/src/sessions/session-callbacks.ts +3 -14
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +5 -1
- package/src/tenant-lifecycle/feature.ts +15 -1
- package/src/user/feature.ts +11 -1
- package/src/user/handlers/update.write.ts +8 -1
- package/src/user/index.ts +1 -0
- package/src/user/principal-status.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.266.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>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.266.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.266.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.266.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.266.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.266.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.266.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.266.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.266.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// End-to-end via real HTTP: switch-tenant and login route through
|
|
2
|
+
// dispatcher.resolveActiveMembership. No sessions feature mounted — proves principal_blocked is enforced at membership-resolution time, not session revocation.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import {
|
|
9
|
+
setupTestStack,
|
|
10
|
+
type TestStack,
|
|
11
|
+
TestUsers,
|
|
12
|
+
testTenantId,
|
|
13
|
+
unsafeCreateEntityTable,
|
|
14
|
+
unsafePushTables,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import {
|
|
17
|
+
createTestEnvelopeCipher,
|
|
18
|
+
resetTestTables,
|
|
19
|
+
updateRows,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
21
|
+
import {
|
|
22
|
+
createComplianceProfilesFeature,
|
|
23
|
+
tenantComplianceProfileEntity,
|
|
24
|
+
tenantComplianceProfileTable,
|
|
25
|
+
} from "../../compliance-profiles";
|
|
26
|
+
import { createConfigFeature } from "../../config";
|
|
27
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
28
|
+
import { configValuesTable } from "../../config/table";
|
|
29
|
+
import { hashPassword } from "../../shared";
|
|
30
|
+
import {
|
|
31
|
+
createTenantFeature,
|
|
32
|
+
TenantHandlers,
|
|
33
|
+
type TenantLifecycleStatus,
|
|
34
|
+
tenantMembershipsTable,
|
|
35
|
+
} from "../../tenant";
|
|
36
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
37
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
38
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
39
|
+
import { resetTenantLifecycleGateCacheForTests } from "../../tenant-lifecycle/lifecycle-gate";
|
|
40
|
+
import { createUserFeature, USER_STATUS, UserHandlers, userEntity, userTable } from "../../user";
|
|
41
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
42
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
43
|
+
|
|
44
|
+
let stack: TestStack;
|
|
45
|
+
|
|
46
|
+
const TENANT_A: TenantId = testTenantId(1);
|
|
47
|
+
const TENANT_B: TenantId = testTenantId(2);
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
51
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
52
|
+
|
|
53
|
+
stack = await setupTestStack({
|
|
54
|
+
features: [
|
|
55
|
+
createConfigFeature(),
|
|
56
|
+
createUserFeature(),
|
|
57
|
+
createTenantFeature(),
|
|
58
|
+
createComplianceProfilesFeature(),
|
|
59
|
+
createTenantLifecycleFeature(),
|
|
60
|
+
createAuthEmailPasswordFeature(),
|
|
61
|
+
],
|
|
62
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
63
|
+
authConfig: {
|
|
64
|
+
membershipQuery: "tenant:query:memberships",
|
|
65
|
+
loginHandler: AuthHandlers.login,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
70
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
71
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
72
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await stack.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
// Statuses below are written directly via updateRows, bypassing the
|
|
81
|
+
// write handlers that invalidate the lifecycle-gate cache on a real
|
|
82
|
+
// request/cancel-destruction — clear it so a later test reusing TENANT_B
|
|
83
|
+
// doesn't read an earlier test's cached status (mirrors
|
|
84
|
+
// tenant-lifecycle.integration.test.ts).
|
|
85
|
+
resetTenantLifecycleGateCacheForTests();
|
|
86
|
+
await resetTestTables(stack.db, [
|
|
87
|
+
userTable,
|
|
88
|
+
tenantTable,
|
|
89
|
+
tenantComplianceProfileTable,
|
|
90
|
+
tenantMembershipsTable,
|
|
91
|
+
eventsTable,
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function createTenant(id: TenantId): Promise<void> {
|
|
96
|
+
// testTenantId(n) shares the "00000000-0000-4000-8000-" prefix across
|
|
97
|
+
// every n — the differing suffix must drive the unique `key`, not a
|
|
98
|
+
// slice(0, 8) of the id (which would collide for every test tenant).
|
|
99
|
+
await stack.http.writeOk(
|
|
100
|
+
TenantHandlers.create,
|
|
101
|
+
{ id, key: `t-${id.slice(-8)}`, name: "Tenant" },
|
|
102
|
+
TestUsers.systemAdmin,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function setTenantStatus(id: TenantId, status: TenantLifecycleStatus): Promise<void> {
|
|
107
|
+
await updateRows(stack.db, tenantTable, { status }, { id });
|
|
108
|
+
resetTenantLifecycleGateCacheForTests();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function createUser(email: string, password: string): Promise<string> {
|
|
112
|
+
const hash = await hashPassword(password);
|
|
113
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
114
|
+
UserHandlers.create,
|
|
115
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
116
|
+
TestUsers.systemAdmin,
|
|
117
|
+
);
|
|
118
|
+
return created.id;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function addMembership(
|
|
122
|
+
userId: string,
|
|
123
|
+
tenantId: TenantId,
|
|
124
|
+
roles: readonly string[] = ["Member"],
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function tokenFor(userId: string, tenantId: TenantId): Promise<string> {
|
|
130
|
+
return stack.jwt.sign({ id: userId, tenantId, roles: ["Member"] });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function switchTenant(token: string, tenantId: TenantId) {
|
|
134
|
+
return stack.http.raw(
|
|
135
|
+
"POST",
|
|
136
|
+
"/api/auth/switch-tenant",
|
|
137
|
+
{ tenantId },
|
|
138
|
+
{ Authorization: `Bearer ${token}` },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("switch-tenant :: active-membership building block", () => {
|
|
143
|
+
test("switching into a tenant with no membership → 403 not_a_member", async () => {
|
|
144
|
+
await createTenant(TENANT_A);
|
|
145
|
+
await createTenant(TENANT_B);
|
|
146
|
+
const userId = await createUser("nomember@example.com", "pw-long-enough-1");
|
|
147
|
+
await addMembership(userId, TENANT_A);
|
|
148
|
+
|
|
149
|
+
const token = await tokenFor(userId, TENANT_A);
|
|
150
|
+
const res = await switchTenant(token, TENANT_B);
|
|
151
|
+
|
|
152
|
+
expect(res.status).toBe(403);
|
|
153
|
+
const body = (await res.json()) as { error?: string };
|
|
154
|
+
expect(body.error).toBe("not_a_member");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("switching as a restricted principal who IS a member of the target → 403 principal_blocked (JWT itself stays valid, no sessions feature to revoke it)", async () => {
|
|
158
|
+
await createTenant(TENANT_A);
|
|
159
|
+
await createTenant(TENANT_B);
|
|
160
|
+
const userId = await createUser("restricted@example.com", "pw-long-enough-2");
|
|
161
|
+
await addMembership(userId, TENANT_A);
|
|
162
|
+
await addMembership(userId, TENANT_B);
|
|
163
|
+
await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
|
|
164
|
+
|
|
165
|
+
const token = await tokenFor(userId, TENANT_A);
|
|
166
|
+
const res = await switchTenant(token, TENANT_B);
|
|
167
|
+
|
|
168
|
+
expect(res.status).toBe(403);
|
|
169
|
+
const body = (await res.json()) as { error?: string };
|
|
170
|
+
expect(body.error).toBe("principal_blocked");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("switching into a tenant mid-teardown (destroying/destroyFailed/destroyed) → 410 tenant_unavailable; destroyRequested still allows it (cancel window)", async () => {
|
|
174
|
+
await createTenant(TENANT_A);
|
|
175
|
+
await createTenant(TENANT_B);
|
|
176
|
+
const userId = await createUser("teardown@example.com", "pw-long-enough-3");
|
|
177
|
+
await addMembership(userId, TENANT_A);
|
|
178
|
+
await addMembership(userId, TENANT_B);
|
|
179
|
+
const token = await tokenFor(userId, TENANT_A);
|
|
180
|
+
|
|
181
|
+
for (const status of ["destroying", "destroyFailed", "destroyed"] as const) {
|
|
182
|
+
await setTenantStatus(TENANT_B, status);
|
|
183
|
+
const res = await switchTenant(token, TENANT_B);
|
|
184
|
+
expect(res.status).toBe(410);
|
|
185
|
+
expect(((await res.json()) as { error?: string }).error).toBe("tenant_unavailable");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await setTenantStatus(TENANT_B, "destroyRequested");
|
|
189
|
+
const pendingRes = await switchTenant(token, TENANT_B);
|
|
190
|
+
expect(pendingRes.status).toBe(200);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a non-member switching into a destroying tenant still gets not_a_member, not tenant_unavailable (membership checked before lifecycle — a non-member must not learn the target's teardown state)", async () => {
|
|
194
|
+
await createTenant(TENANT_A);
|
|
195
|
+
await createTenant(TENANT_B);
|
|
196
|
+
const userId = await createUser("outsider@example.com", "pw-long-enough-4");
|
|
197
|
+
await addMembership(userId, TENANT_A);
|
|
198
|
+
await setTenantStatus(TENANT_B, "destroying");
|
|
199
|
+
|
|
200
|
+
const token = await tokenFor(userId, TENANT_A);
|
|
201
|
+
const res = await switchTenant(token, TENANT_B);
|
|
202
|
+
|
|
203
|
+
expect(res.status).toBe(403);
|
|
204
|
+
expect(((await res.json()) as { error?: string }).error).toBe("not_a_member");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("login :: active-membership building block", () => {
|
|
209
|
+
test("restricted user login → rejected with the existing account_restricted error (gateEnforceAccountStatus runs before gateResolveMembership, behaviour unchanged)", async () => {
|
|
210
|
+
await createTenant(TENANT_A);
|
|
211
|
+
const userId = await createUser("restrictedlogin@example.com", "pw-long-enough-5");
|
|
212
|
+
await addMembership(userId, TENANT_A);
|
|
213
|
+
await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
|
|
214
|
+
|
|
215
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
216
|
+
email: "restrictedlogin@example.com",
|
|
217
|
+
password: "pw-long-enough-5",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(res.status).toBe(422);
|
|
221
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
222
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.accountRestricted);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("active user whose last-active tenant is destroying falls through to a second active membership", async () => {
|
|
226
|
+
await createTenant(TENANT_A);
|
|
227
|
+
await createTenant(TENANT_B);
|
|
228
|
+
const userId = await createUser("fallback@example.com", "pw-long-enough-6");
|
|
229
|
+
await addMembership(userId, TENANT_A);
|
|
230
|
+
await addMembership(userId, TENANT_B);
|
|
231
|
+
await updateRows(stack.db, userTable, { lastActiveTenantId: TENANT_A }, { id: userId });
|
|
232
|
+
await setTenantStatus(TENANT_A, "destroying");
|
|
233
|
+
|
|
234
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
235
|
+
email: "fallback@example.com",
|
|
236
|
+
password: "pw-long-enough-6",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
const body = (await res.json()) as { user: { tenantId: string } };
|
|
241
|
+
expect(body.user.tenantId).toBe(TENANT_B);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("dispatcher.resolveActiveMembership", () => {
|
|
246
|
+
test("unknown principal (no persisted user row) with a membership resolves active", async () => {
|
|
247
|
+
await createTenant(TENANT_A);
|
|
248
|
+
const unknownUserId = crypto.randomUUID();
|
|
249
|
+
await addMembership(unknownUserId, TENANT_A);
|
|
250
|
+
|
|
251
|
+
const result = await stack.dispatcher.resolveActiveMembership(unknownUserId, TENANT_A);
|
|
252
|
+
|
|
253
|
+
expect(result.kind).toBe("active");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createSystemUser,
|
|
5
5
|
defineWriteHandler,
|
|
6
6
|
type SessionUser,
|
|
7
|
+
TENANT_MEMBERSHIPS_QUERY,
|
|
7
8
|
type TenantId,
|
|
8
9
|
type WriteResult,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -178,7 +179,9 @@ export async function gateResolveMembership(
|
|
|
178
179
|
systemUser: SessionUser,
|
|
179
180
|
found: AuthUserRow,
|
|
180
181
|
): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
|
|
181
|
-
|
|
182
|
+
// Still needed for candidate ORDER (preferred tenant first) — the actual
|
|
183
|
+
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
184
|
+
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
182
185
|
userId: found.id,
|
|
183
186
|
})) as Array<Membership>; // @cast-boundary db-runner
|
|
184
187
|
|
|
@@ -190,17 +193,34 @@ export async function gateResolveMembership(
|
|
|
190
193
|
found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
|
|
191
194
|
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
192
195
|
: undefined;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
196
|
+
const candidates = preferred
|
|
197
|
+
? [preferred, ...memberships.filter((m) => m !== preferred)]
|
|
198
|
+
: memberships;
|
|
197
199
|
|
|
198
200
|
const globalRoles = parseRoles(found.roles ?? null);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
|
|
202
|
+
for (const candidate of candidates) {
|
|
203
|
+
const active = await ctx.resolveActiveMembership(found.id, candidate.tenantId);
|
|
204
|
+
if (active.kind === "rejected") {
|
|
205
|
+
// A blocked principal is blocked for every tenant, so stop here;
|
|
206
|
+
// not_a_member/tenant_teardown are per-tenant — try the next candidate.
|
|
207
|
+
if (active.reason === "principal_blocked") {
|
|
208
|
+
return reject(invalidCredentials());
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const chosen: Membership = {
|
|
213
|
+
tenantId: active.membership.tenantId,
|
|
214
|
+
roles: active.membership.roles,
|
|
215
|
+
};
|
|
216
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
217
|
+
// roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
|
|
218
|
+
// rebuild-resurrected role.
|
|
219
|
+
const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
|
|
220
|
+
return ok({ chosen, mergedRoles });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return reject(noMembership());
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
// MFA challenge / setup-required / proceed. Excludes "auth-session" (this
|
|
@@ -266,8 +286,8 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
266
286
|
escapeHatch: {
|
|
267
287
|
reason:
|
|
268
288
|
"Unauthenticated login has no caller identity yet — it looks up the user row by " +
|
|
269
|
-
"email
|
|
270
|
-
"session exists.",
|
|
289
|
+
"email via ctx.queryAs(SYSTEM, ...) and resolves tenant membership via " +
|
|
290
|
+
"ctx.resolveActiveMembership before a session exists.",
|
|
271
291
|
},
|
|
272
292
|
description:
|
|
273
293
|
"Signs a user in with email and password, running the lockout, email-verification, account-status, tenant-membership and MFA gates, and answering with a session or with an MFA challenge or setup requirement.",
|
|
@@ -16,13 +16,19 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
17
17
|
import { AuthHandlers as AuthEmailPasswordHandlers } from "../../auth-email-password/constants";
|
|
18
18
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
19
|
+
import {
|
|
20
|
+
createComplianceProfilesFeature,
|
|
21
|
+
tenantComplianceProfileEntity,
|
|
22
|
+
} from "../../compliance-profiles";
|
|
19
23
|
import { createConfigFeature } from "../../config";
|
|
20
24
|
import { createConfigResolver } from "../../config/resolver";
|
|
21
25
|
import { configValuesTable } from "../../config/table";
|
|
22
26
|
import { hashPassword } from "../../shared";
|
|
23
27
|
import { createTenantFeature } from "../../tenant";
|
|
24
28
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
25
|
-
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
29
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
30
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
31
|
+
import { resetTenantLifecycleGateCacheForTests } from "../../tenant-lifecycle/lifecycle-gate";
|
|
26
32
|
import { USER_STATUS } from "../../user";
|
|
27
33
|
import { createUserFeature } from "../../user/feature";
|
|
28
34
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
@@ -62,6 +68,8 @@ beforeAll(async () => {
|
|
|
62
68
|
createConfigFeature(),
|
|
63
69
|
createUserFeature(),
|
|
64
70
|
createTenantFeature(),
|
|
71
|
+
createComplianceProfilesFeature(),
|
|
72
|
+
createTenantLifecycleFeature(),
|
|
65
73
|
authMfaFeature,
|
|
66
74
|
createAuthEmailPasswordFeature({
|
|
67
75
|
mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
|
|
@@ -75,6 +83,7 @@ beforeAll(async () => {
|
|
|
75
83
|
});
|
|
76
84
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
85
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
86
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
78
87
|
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
79
88
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
80
89
|
});
|
|
@@ -350,6 +359,37 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
|
|
|
350
359
|
expectErrorIncludes(err, "invalid_challenge_token");
|
|
351
360
|
});
|
|
352
361
|
|
|
362
|
+
test("tenant enters teardown between login and verify → challenge rejected", async () => {
|
|
363
|
+
const { user, secret } = await enableMfaFor(11);
|
|
364
|
+
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
365
|
+
|
|
366
|
+
// user.tenantId is the shared default test tenant (TestUsers.admin) —
|
|
367
|
+
// restore it to "active" afterwards so later tests reusing it aren't affected.
|
|
368
|
+
await seedRow(stack.db, tenantTable, {
|
|
369
|
+
id: user.tenantId,
|
|
370
|
+
tenantId: user.tenantId,
|
|
371
|
+
key: `t-${user.tenantId.slice(-8)}`,
|
|
372
|
+
name: "Tenant",
|
|
373
|
+
status: "destroying",
|
|
374
|
+
});
|
|
375
|
+
resetTenantLifecycleGateCacheForTests();
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const err = await stack.http.writeErr(
|
|
379
|
+
AuthMfaHandlers.verify,
|
|
380
|
+
{ challengeToken, code: currentTotpCode(secret) },
|
|
381
|
+
GUEST,
|
|
382
|
+
);
|
|
383
|
+
expectErrorIncludes(err, "invalid_challenge_token");
|
|
384
|
+
} finally {
|
|
385
|
+
await asRawClient(stack.db).unsafe(
|
|
386
|
+
`UPDATE "${tenantTable.tableName}" SET status = $1 WHERE id = $2`,
|
|
387
|
+
["active", user.tenantId],
|
|
388
|
+
);
|
|
389
|
+
resetTenantLifecycleGateCacheForTests();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
353
393
|
test("user row homed in a DIFFERENT tenant than the challenge → verify succeeds (#1235)", async () => {
|
|
354
394
|
const { user, secret } = await enableMfaFor(8);
|
|
355
395
|
|
|
@@ -66,8 +66,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
66
66
|
access: { roles: ["all"] },
|
|
67
67
|
escapeHatch: {
|
|
68
68
|
reason:
|
|
69
|
-
"Pre-auth MFA enrollment step has no session yet — re-checks status
|
|
70
|
-
"
|
|
69
|
+
"Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
|
|
70
|
+
"user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user.",
|
|
71
71
|
},
|
|
72
72
|
description:
|
|
73
73
|
"Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
|
|
@@ -165,11 +165,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
165
165
|
|
|
166
166
|
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
167
167
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
})) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
|
|
171
|
-
const membership = memberships.find((m) => m.tenantId === tenantId);
|
|
172
|
-
if (!membership) return invalidSetupToken();
|
|
168
|
+
const active = await ctx.resolveActiveMembership(userId, tenantId);
|
|
169
|
+
if (active.kind === "rejected") return invalidSetupToken();
|
|
173
170
|
|
|
174
171
|
const result = await executor.create(
|
|
175
172
|
{
|
|
@@ -195,7 +192,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
195
192
|
// roles from the membership portion (globalRoles keeps SystemAdmin) —
|
|
196
193
|
// read-time backstop against a rebuild-resurrected role, same as
|
|
197
194
|
// verify.write.ts.
|
|
198
|
-
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
195
|
+
const mergedRoles = buildSessionRoles(globalRoles, active.membership.roles);
|
|
199
196
|
|
|
200
197
|
const baseSession: SessionUser = {
|
|
201
198
|
id: userId,
|
|
@@ -49,7 +49,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
49
49
|
escapeHatch: {
|
|
50
50
|
reason:
|
|
51
51
|
"Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
|
|
52
|
-
"user:findForAuth
|
|
52
|
+
"user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names.",
|
|
53
53
|
},
|
|
54
54
|
description:
|
|
55
55
|
"Finishes a two-step sign-in by checking a TOTP or recovery code against the challenge token that login handed back, under a per-account attempt cap, and derives the resulting session.",
|
|
@@ -166,19 +166,14 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
166
166
|
|
|
167
167
|
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Membership revoked between login and verify (race, or a stale
|
|
174
|
-
// challenge token from before removal) — login.write.ts would have
|
|
175
|
-
// refused with noMembership() at the same juncture; mirror it here
|
|
176
|
-
// instead of silently falling back to globalRoles only.
|
|
177
|
-
if (!membership) return invalidChallengeToken();
|
|
169
|
+
// Membership revoked or principal blocked between login and verify —
|
|
170
|
+
// mirror login.write.ts's refusal instead of falling back to globalRoles only.
|
|
171
|
+
const active = await ctx.resolveActiveMembership(userId, tenantId);
|
|
172
|
+
if (active.kind === "rejected") return invalidChallengeToken();
|
|
178
173
|
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
179
174
|
// roles from the membership portion (globalRoles keeps SystemAdmin) —
|
|
180
175
|
// read-time backstop against a rebuild-resurrected role.
|
|
181
|
-
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
176
|
+
const mergedRoles = buildSessionRoles(globalRoles, active.membership.roles);
|
|
182
177
|
|
|
183
178
|
const baseSession: SessionUser = {
|
|
184
179
|
id: userId,
|
|
@@ -18,7 +18,7 @@ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
|
18
18
|
import { Temporal } from "temporal-polyfill";
|
|
19
19
|
import { encryptForDirectWrite } from "../shared";
|
|
20
20
|
import { tenantMembershipsTable } from "../tenant";
|
|
21
|
-
import {
|
|
21
|
+
import { isPrincipalBlocked, type UserStatus, userTable } from "../user";
|
|
22
22
|
import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS } from "./constants";
|
|
23
23
|
import { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
24
24
|
import {
|
|
@@ -27,19 +27,8 @@ import {
|
|
|
27
27
|
sessionRevokedSchema,
|
|
28
28
|
} from "./session-revoked-event";
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
// their session to reach cancel-deletion.
|
|
33
|
-
const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
|
|
34
|
-
USER_STATUS.Restricted,
|
|
35
|
-
USER_STATUS.Deleted,
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Shared with personal-access-tokens' resolver — a PAT belonging to a
|
|
39
|
-
// locked-out principal must be refused the same way a live session is.
|
|
40
|
-
export function isPrincipalBlocked(status: UserStatus): boolean {
|
|
41
|
-
return BLOCKED_STATUSES.has(status);
|
|
42
|
-
}
|
|
30
|
+
// Re-exported so existing `../sessions` importers keep working unchanged.
|
|
31
|
+
export { isPrincipalBlocked };
|
|
43
32
|
|
|
44
33
|
// Why the callbacks live at the raw-DB level rather than going through the
|
|
45
34
|
// dispatcher: session-create/revoke/check run on the hot path of every
|
|
@@ -29,6 +29,8 @@ import type { Hono } from "hono";
|
|
|
29
29
|
import { createConfigFeature } from "../../config/feature";
|
|
30
30
|
import { createConfigResolver } from "../../config/resolver";
|
|
31
31
|
import { configValuesTable } from "../../config/table";
|
|
32
|
+
import { createUserFeature } from "../../user/feature";
|
|
33
|
+
import { userEntity } from "../../user/schema/user";
|
|
32
34
|
import { TenantHandlers, TenantQueries } from "../constants";
|
|
33
35
|
import { createTenantFeature } from "../feature";
|
|
34
36
|
import { tenantMembershipsTable } from "../membership-table";
|
|
@@ -65,12 +67,14 @@ beforeAll(async () => {
|
|
|
65
67
|
db = testDb.db;
|
|
66
68
|
|
|
67
69
|
await unsafeCreateEntityTable(db, tenantEntity);
|
|
70
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
68
71
|
await unsafePushTables(db, { tenantMembershipsTable, configValuesTable });
|
|
69
72
|
await createEventsTable(db);
|
|
70
73
|
|
|
71
74
|
const configFeature = createConfigFeature();
|
|
75
|
+
const userFeature = createUserFeature();
|
|
72
76
|
const tenantFeature = createTenantFeature();
|
|
73
|
-
const registry = createRegistry([configFeature, tenantFeature, billingFeature]);
|
|
77
|
+
const registry = createRegistry([configFeature, userFeature, tenantFeature, billingFeature]);
|
|
74
78
|
const resolver = createConfigResolver();
|
|
75
79
|
|
|
76
80
|
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
EXT_SEARCH_ADAPTER,
|
|
6
6
|
EXT_STORAGE_PROVIDER,
|
|
7
7
|
EXT_TENANT_DATA,
|
|
8
|
+
EXT_TENANT_LIFECYCLE_STATUS,
|
|
8
9
|
type FeatureDefinition,
|
|
10
|
+
type TenantLifecycleStatusPlugin,
|
|
9
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
12
|
import { validateTenantDataHookCoverage } from "./boot-checks";
|
|
11
13
|
import {
|
|
@@ -32,7 +34,14 @@ import {
|
|
|
32
34
|
} from "./events";
|
|
33
35
|
import { cancelDestructionWrite } from "./handlers/cancel-destruction.write";
|
|
34
36
|
import { requestDestructionWrite } from "./handlers/request-destruction.write";
|
|
35
|
-
import { runTenantDestructionSweep } from "./run-tenant-destroy";
|
|
37
|
+
import { resolveTenantLifecycleGate, runTenantDestructionSweep } from "./run-tenant-destroy";
|
|
38
|
+
|
|
39
|
+
const tenantLifecycleStatusPlugin: TenantLifecycleStatusPlugin = {
|
|
40
|
+
async resolveStatus(tenantId, { db }) {
|
|
41
|
+
const gate = await resolveTenantLifecycleGate(db, tenantId);
|
|
42
|
+
return gate ? { status: gate.status } : null;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
36
45
|
|
|
37
46
|
export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
38
47
|
return defineFeature("tenant-lifecycle", (r) => {
|
|
@@ -54,6 +63,11 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
|
|
|
54
63
|
r.extendsRegistrar(EXT_STORAGE_PROVIDER, {});
|
|
55
64
|
r.extendsRegistrar(EXT_INFRA_RESOURCE, {});
|
|
56
65
|
|
|
66
|
+
// Self-extension, same pattern as `user`/EXT_PRINCIPAL_STATUS — declares
|
|
67
|
+
// AND fulfils tenantLifecycleStatus so teardown state is visible with no per-app wiring.
|
|
68
|
+
r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
|
|
69
|
+
r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "tenant-lifecycle", tenantLifecycleStatusPlugin);
|
|
70
|
+
|
|
57
71
|
// GDPR-storage guard V4 (#1314) — moved off the framework-internal
|
|
58
72
|
// boot-validator onto this feature's own r.bootCheck(), since it owns
|
|
59
73
|
// EXT_TENANT_DATA: its own mount is the trigger, matching the original
|
package/src/user/feature.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
EXT_PRINCIPAL_STATUS,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
6
|
import { createWrite } from "./handlers/create.write";
|
|
3
7
|
import { detailQuery } from "./handlers/detail.query";
|
|
4
8
|
import { findForAuthQuery } from "./handlers/find-for-auth.query";
|
|
@@ -6,6 +10,7 @@ import { listQuery } from "./handlers/list.query";
|
|
|
6
10
|
import { meQuery } from "./handlers/me.query";
|
|
7
11
|
import { updateWrite } from "./handlers/update.write";
|
|
8
12
|
import { USER_I18N } from "./i18n";
|
|
13
|
+
import { principalStatusPlugin } from "./principal-status";
|
|
9
14
|
import { userEntity } from "./schema/user";
|
|
10
15
|
import { userEditScreen, userListScreen } from "./screens";
|
|
11
16
|
|
|
@@ -25,6 +30,11 @@ export function createUserFeature(): FeatureDefinition {
|
|
|
25
30
|
r.systemScope();
|
|
26
31
|
r.entity("user", userEntity);
|
|
27
32
|
|
|
33
|
+
// Self-extension: `user` declares AND fulfils principalStatus (precedent:
|
|
34
|
+
// tier-engine/feature.ts) — wires the blocked-principal check into every stack that mounts `user`.
|
|
35
|
+
r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
|
|
36
|
+
r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin);
|
|
37
|
+
|
|
28
38
|
const handlers = {
|
|
29
39
|
create: r.writeHandler(createWrite),
|
|
30
40
|
update: r.writeHandler(updateWrite),
|
|
@@ -40,7 +40,14 @@ export const updateWrite = defineWriteHandler({
|
|
|
40
40
|
roles: rolesInputSchema.optional(),
|
|
41
41
|
}),
|
|
42
42
|
}),
|
|
43
|
-
access: {
|
|
43
|
+
access: {
|
|
44
|
+
openToAll: {
|
|
45
|
+
reason:
|
|
46
|
+
"Any signed-in user edits their own profile and privileged actors edit any user; " +
|
|
47
|
+
"the self-or-privileged check lives in the handler body, not in userEntity.access.write.",
|
|
48
|
+
personalData: "tenant-members",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
44
51
|
description:
|
|
45
52
|
"Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
|
|
46
53
|
handler: async (event, ctx) => {
|
package/src/user/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
type UserStreamBackfillResult,
|
|
6
6
|
} from "./db/queries/stream-tenant-backfill";
|
|
7
7
|
export { createUserFeature } from "./feature";
|
|
8
|
+
export { isPrincipalBlocked, principalStatusPlugin } from "./principal-status";
|
|
8
9
|
export type { UserStatus } from "./schema/user";
|
|
9
10
|
export {
|
|
10
11
|
USER_ANONYMIZED_DISPLAY_NAME,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { PrincipalStatus, PrincipalStatusPlugin } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { USER_STATUS, type UserStatus, userTable } from "./schema/user";
|
|
4
|
+
|
|
5
|
+
// Locked accounts whose live sessions must be refused. deletionRequested is
|
|
6
|
+
// intentionally absent — it's a reversible grace period and the user needs their session to reach cancel-deletion.
|
|
7
|
+
const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
|
|
8
|
+
USER_STATUS.Restricted,
|
|
9
|
+
USER_STATUS.Deleted,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
// Shared with personal-access-tokens' resolver and sessions' sessionChecker
|
|
13
|
+
// — a PAT or a live session belonging to a locked-out principal is refused the same way.
|
|
14
|
+
export function isPrincipalBlocked(status: UserStatus): boolean {
|
|
15
|
+
return BLOCKED_STATUSES.has(status);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin)
|
|
19
|
+
// so resolveActiveMembershipFn can reject a blocked principal without knowing about `user`'s table.
|
|
20
|
+
export const principalStatusPlugin: PrincipalStatusPlugin = {
|
|
21
|
+
async resolveStatus(userId, { db }): Promise<PrincipalStatus> {
|
|
22
|
+
const row = await fetchOne<{ status: UserStatus }>(db, userTable, { id: userId });
|
|
23
|
+
if (!row) return "unknown";
|
|
24
|
+
return isPrincipalBlocked(row.status) ? "blocked" : "active";
|
|
25
|
+
},
|
|
26
|
+
};
|