@cosmicdrift/kumiko-bundled-features 0.165.1 → 0.165.2
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/__tests__/access-invalidation-stream.integration.test.ts +2 -14
- package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
- package/src/auth-email-password/handlers/login.write.ts +196 -163
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +80 -5
- package/src/config/handlers/reencrypt.job.ts +3 -3
- package/src/inbound-mail-foundation/entities.ts +3 -3
- package/src/personal-access-tokens/feature.ts +2 -2
- package/src/personal-access-tokens/schema/api-token.ts +3 -3
- package/src/sessions/__tests__/sessions.integration.test.ts +19 -3
- package/src/sessions/feature.ts +9 -6
- package/src/sessions/schema/user-session.ts +6 -7
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +46 -67
- package/src/user-data-rights/__tests__/download.integration.test.ts +13 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +22 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.165.
|
|
3
|
+
"version": "0.165.2",
|
|
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>",
|
|
@@ -119,11 +119,11 @@
|
|
|
119
119
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.165.
|
|
123
|
-
"@cosmicdrift/kumiko-framework": "0.165.
|
|
124
|
-
"@cosmicdrift/kumiko-headless": "0.165.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer": "0.165.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer-web": "0.165.
|
|
122
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.2",
|
|
123
|
+
"@cosmicdrift/kumiko-framework": "0.165.2",
|
|
124
|
+
"@cosmicdrift/kumiko-headless": "0.165.2",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer": "0.165.2",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer-web": "0.165.2",
|
|
127
127
|
"@mollie/api-client": "^4.5.0",
|
|
128
128
|
"imapflow": "^1.3.3",
|
|
129
129
|
"mailparser": "^3.9.8",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"LICENSE"
|
|
149
149
|
],
|
|
150
150
|
"peerDependencies": {
|
|
151
|
-
"@cosmicdrift/kumiko-types": "^0.165.
|
|
151
|
+
"@cosmicdrift/kumiko-types": "^0.165.2"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
154
|
"@testing-library/user-event": "^14.6.1",
|
|
@@ -172,13 +172,7 @@ describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
|
|
|
172
172
|
|
|
173
173
|
releaseNextChunk?.();
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
await iter.next();
|
|
178
|
-
} catch (e) {
|
|
179
|
-
thrown = e;
|
|
180
|
-
}
|
|
181
|
-
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
175
|
+
await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
|
|
182
176
|
});
|
|
183
177
|
|
|
184
178
|
test("tenant role strip mid-stream terminates the open HTTP SSE stream", async () => {
|
|
@@ -206,12 +200,6 @@ describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
|
|
|
206
200
|
|
|
207
201
|
releaseNextChunk?.();
|
|
208
202
|
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
await iter.next();
|
|
212
|
-
} catch (e) {
|
|
213
|
-
thrown = e;
|
|
214
|
-
}
|
|
215
|
-
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
203
|
+
await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
|
|
216
204
|
});
|
|
217
205
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { USER_STATUS } from "../../user";
|
|
3
|
+
import type { AuthUserRow } from "../auth-user-row";
|
|
4
|
+
import { gateEnforceAccountStatus, gateEnforceEmailVerified } from "../handlers/login.write";
|
|
5
|
+
|
|
6
|
+
function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
|
|
7
|
+
return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("login.write gates (fw#1284)", () => {
|
|
11
|
+
test("gateEnforceEmailVerified rejects when strict and unverified", () => {
|
|
12
|
+
const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
|
|
13
|
+
expect(g.ok).toBe(false);
|
|
14
|
+
if (!g.ok) expect(g.result.isSuccess).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("gateEnforceEmailVerified passes when not strict", () => {
|
|
18
|
+
expect(gateEnforceEmailVerified(row({ emailVerified: false }), false).ok).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("gateEnforceAccountStatus rejects restricted", () => {
|
|
22
|
+
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
|
|
23
|
+
expect(g.ok).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
|
|
27
|
+
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
|
|
28
|
+
expect(g.ok).toBe(false);
|
|
29
|
+
if (!g.ok) {
|
|
30
|
+
expect(g.result.isSuccess).toBe(false);
|
|
31
|
+
// anti-enumeration — same family as invalid credentials
|
|
32
|
+
expect(g.result).toMatchObject({ isSuccess: false });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("gateEnforceAccountStatus passes active", () => {
|
|
37
|
+
expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -11,7 +11,7 @@ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { verifyDummyPassword, verifyPassword } from "../../shared";
|
|
13
13
|
import { USER_STATUS, UserQueries } from "../../user";
|
|
14
|
-
import { parseAuthUserRow } from "../auth-user-row";
|
|
14
|
+
import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
|
|
15
15
|
import {
|
|
16
16
|
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
17
17
|
AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
|
|
@@ -72,6 +72,170 @@ type LoginResult =
|
|
|
72
72
|
| { readonly kind: "mfa-challenge"; readonly challengeToken: string }
|
|
73
73
|
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
|
|
74
74
|
|
|
75
|
+
type Membership = { readonly tenantId: TenantId; readonly roles: readonly string[] };
|
|
76
|
+
|
|
77
|
+
type GateReject = { readonly ok: false; readonly result: WriteResult<LoginResult> };
|
|
78
|
+
type GateOk<T> = { readonly ok: true; readonly value: T };
|
|
79
|
+
type GateOutcome<T> = GateReject | GateOk<T>;
|
|
80
|
+
|
|
81
|
+
function reject(result: WriteResult<LoginResult>): GateReject {
|
|
82
|
+
return { ok: false, result };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ok<T>(value: T): GateOk<T> {
|
|
86
|
+
return { ok: true, value };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Uniform response on any credential miss — burns argon2 cost (#774). */
|
|
90
|
+
export async function gateResolveAuthUser(
|
|
91
|
+
ctx: HandlerContext,
|
|
92
|
+
systemUser: SessionUser,
|
|
93
|
+
email: string,
|
|
94
|
+
password: string,
|
|
95
|
+
): Promise<GateOutcome<AuthUserRow>> {
|
|
96
|
+
const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
|
|
97
|
+
if (!found?.passwordHash || found.isDeleted) {
|
|
98
|
+
await verifyDummyPassword(password);
|
|
99
|
+
return reject(invalidCredentials());
|
|
100
|
+
}
|
|
101
|
+
return ok(found);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Lockout BEFORE password verify — locked accounts can't be password-probed.
|
|
106
|
+
* Fail-open without Redis (IP rate-limit still covers partially).
|
|
107
|
+
*/
|
|
108
|
+
export async function gateEnforceLockout(
|
|
109
|
+
ctx: HandlerContext,
|
|
110
|
+
userId: string,
|
|
111
|
+
): Promise<GateOutcome<undefined>> {
|
|
112
|
+
if (!ctx.redis) return ok(undefined);
|
|
113
|
+
const state = await getLockoutState(ctx.redis, userId);
|
|
114
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (state.lockedUntil > now) {
|
|
117
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
118
|
+
return reject(accountLocked(retryAfterSeconds));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return ok(undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Verify password; record miss / clear lockout on hit. */
|
|
125
|
+
export async function gateVerifyPassword(
|
|
126
|
+
ctx: HandlerContext,
|
|
127
|
+
found: AuthUserRow,
|
|
128
|
+
password: string,
|
|
129
|
+
maxFailedAttempts: number,
|
|
130
|
+
lockoutDurationMinutes: number,
|
|
131
|
+
): Promise<GateOutcome<undefined>> {
|
|
132
|
+
const passwordHash = found.passwordHash;
|
|
133
|
+
if (!passwordHash) return reject(invalidCredentials());
|
|
134
|
+
const passwordOk = await verifyPassword(passwordHash, password);
|
|
135
|
+
if (!passwordOk) {
|
|
136
|
+
if (ctx.redis) {
|
|
137
|
+
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
138
|
+
}
|
|
139
|
+
return reject(invalidCredentials());
|
|
140
|
+
}
|
|
141
|
+
// Clear before MFA — MFA has its own attempt-cap; don't password-lock
|
|
142
|
+
// users who occasionally mistype across otherwise-successful logins.
|
|
143
|
+
if (ctx.redis) {
|
|
144
|
+
await clearLockoutState(ctx.redis, found.id);
|
|
145
|
+
}
|
|
146
|
+
return ok(undefined);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Strict email verification — after password, before session. */
|
|
150
|
+
export function gateEnforceEmailVerified(
|
|
151
|
+
found: AuthUserRow,
|
|
152
|
+
strictVerification: boolean,
|
|
153
|
+
): GateOutcome<undefined> {
|
|
154
|
+
if (strictVerification && found.emailVerified !== true) {
|
|
155
|
+
return reject(emailNotVerified());
|
|
156
|
+
}
|
|
157
|
+
return ok(undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** DSGVO Art. 18 freeze + forget-path anti-enumeration. */
|
|
161
|
+
export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefined> {
|
|
162
|
+
if (found.status === USER_STATUS.Restricted) {
|
|
163
|
+
return reject(accountRestricted());
|
|
164
|
+
}
|
|
165
|
+
if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
|
|
166
|
+
return reject(invalidCredentials());
|
|
167
|
+
}
|
|
168
|
+
return ok(undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pick membership (last-active preferred); merge global + tenant roles. */
|
|
172
|
+
export async function gateResolveMembership(
|
|
173
|
+
ctx: HandlerContext,
|
|
174
|
+
systemUser: SessionUser,
|
|
175
|
+
found: AuthUserRow,
|
|
176
|
+
): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
|
|
177
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
178
|
+
userId: found.id,
|
|
179
|
+
})) as Array<Membership>; // @cast-boundary db-runner
|
|
180
|
+
|
|
181
|
+
if (memberships.length === 0) {
|
|
182
|
+
return reject(noMembership());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const preferred =
|
|
186
|
+
found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
|
|
187
|
+
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
188
|
+
: undefined;
|
|
189
|
+
const chosen = preferred ?? memberships[0];
|
|
190
|
+
if (!chosen) {
|
|
191
|
+
return reject(noMembership());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const globalRoles = parseRoles(found.roles ?? null);
|
|
195
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
196
|
+
// roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
|
|
197
|
+
// rebuild-resurrected role.
|
|
198
|
+
const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
|
|
199
|
+
return ok({ chosen, mergedRoles });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** MFA challenge / setup-required / proceed. */
|
|
203
|
+
export async function gateEnforceMfa(
|
|
204
|
+
ctx: HandlerContext,
|
|
205
|
+
opts: LoginHandlerOptions,
|
|
206
|
+
userId: string,
|
|
207
|
+
tenantId: TenantId,
|
|
208
|
+
mergedRoles: readonly string[],
|
|
209
|
+
): Promise<GateOutcome<LoginResult | undefined>> {
|
|
210
|
+
if (!opts.mfaStatusChecker) return ok(undefined);
|
|
211
|
+
const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
|
|
212
|
+
if ("challengeToken" in mfaStatus) {
|
|
213
|
+
return ok({ kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken });
|
|
214
|
+
}
|
|
215
|
+
if ("setupRequired" in mfaStatus) {
|
|
216
|
+
return ok({ kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken });
|
|
217
|
+
}
|
|
218
|
+
return ok(undefined);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Auth-claims hooks → session. */
|
|
222
|
+
export async function gateBuildSession(
|
|
223
|
+
ctx: HandlerContext,
|
|
224
|
+
userId: string,
|
|
225
|
+
tenantId: TenantId,
|
|
226
|
+
mergedRoles: readonly string[],
|
|
227
|
+
): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
|
|
228
|
+
const baseSession: SessionUser = {
|
|
229
|
+
id: userId,
|
|
230
|
+
tenantId,
|
|
231
|
+
roles: mergedRoles,
|
|
232
|
+
};
|
|
233
|
+
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
234
|
+
const session: SessionUser =
|
|
235
|
+
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
236
|
+
return ok({ kind: "auth-session", session });
|
|
237
|
+
}
|
|
238
|
+
|
|
75
239
|
// Login — unauthenticated entry point. The route is wired public (no JWT
|
|
76
240
|
// middleware), synthesising a guest SessionUser for the handler's access
|
|
77
241
|
// check. Everything inside the handler goes through ctx.queryAs(system, ...)
|
|
@@ -93,177 +257,46 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
93
257
|
handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
|
|
94
258
|
const systemUser = createSystemUser(SYSTEM_USER_ID);
|
|
95
259
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
260
|
+
const userGate = await gateResolveAuthUser(
|
|
261
|
+
ctx,
|
|
262
|
+
systemUser,
|
|
263
|
+
event.payload.email,
|
|
264
|
+
event.payload.password,
|
|
100
265
|
);
|
|
266
|
+
if (!userGate.ok) return userGate.result;
|
|
267
|
+
const found = userGate.value;
|
|
101
268
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!found?.passwordHash || found.isDeleted) {
|
|
105
|
-
// Burn the same argon2 verify cost as the hit path so response
|
|
106
|
-
// latency doesn't reveal whether the email is registered (#774).
|
|
107
|
-
await verifyDummyPassword(event.payload.password);
|
|
108
|
-
return invalidCredentials();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Lockout gate — runs BEFORE password verification so a locked account
|
|
112
|
-
// can't be bruteforce-probed for passwords (and also can't be probed
|
|
113
|
-
// for a timing-oracle on the bcrypt verify). If Redis isn't wired,
|
|
114
|
-
// lockout is silently skipped — login still works, brute-force
|
|
115
|
-
// protection just degrades to the IP-rate-limiter at the edge.
|
|
116
|
-
//
|
|
117
|
-
// Deliberately fail-open here, unlike auth-mfa's enable-confirm-preauth
|
|
118
|
-
// (which fails closed without Redis): the secret guarded by THIS gate
|
|
119
|
-
// is a full password, not a 6-digit code — locking out every login
|
|
120
|
-
// app-wide because Redis is briefly unavailable is a self-inflicted
|
|
121
|
-
// outage across every tenant, for a backstop that the IP-rate-limiter
|
|
122
|
-
// still partially covers. auth-mfa's blast radius is one user's MFA
|
|
123
|
-
// enrollment, not global login availability — different tradeoff.
|
|
124
|
-
if (ctx.redis) {
|
|
125
|
-
const state = await getLockoutState(ctx.redis, found.id);
|
|
126
|
-
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
127
|
-
const now = Date.now();
|
|
128
|
-
if (state.lockedUntil > now) {
|
|
129
|
-
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
130
|
-
return accountLocked(retryAfterSeconds);
|
|
131
|
-
}
|
|
132
|
-
// lockedUntil in the past — shouldn't normally happen because the
|
|
133
|
-
// Redis TTL on the until-key expires the key at the same moment
|
|
134
|
-
// as the value. Clock skew / replication lag could surface this;
|
|
135
|
-
// fall through to password verification. The counter is NOT
|
|
136
|
-
// reset — next miss re-locks immediately (strict-semantic, see
|
|
137
|
-
// lockout-store.ts).
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const passwordOk = await verifyPassword(found.passwordHash, event.payload.password);
|
|
142
|
-
if (!passwordOk) {
|
|
143
|
-
if (ctx.redis) {
|
|
144
|
-
await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
|
|
145
|
-
}
|
|
146
|
-
return invalidCredentials();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Clear the lockout state as soon as the password is proven — the
|
|
150
|
-
// password itself is the thing this counter guards, and MFA (if
|
|
151
|
-
// gated below) has its own separate attempt-cap. Doing this before
|
|
152
|
-
// the MFA gate matters: without it, an MFA user who occasionally
|
|
153
|
-
// mistypes their password accumulates failures across otherwise-
|
|
154
|
-
// successful logins and eventually gets password-locked out even
|
|
155
|
-
// though every login they completed was legitimate.
|
|
156
|
-
if (ctx.redis) {
|
|
157
|
-
await clearLockoutState(ctx.redis, found.id);
|
|
158
|
-
}
|
|
269
|
+
const lockoutGate = await gateEnforceLockout(ctx, found.id);
|
|
270
|
+
if (!lockoutGate.ok) return lockoutGate.result;
|
|
159
271
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
|
|
169
|
-
// nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
|
|
170
|
-
// (siehe lift-restriction.write.ts Header — typisch via Magic-Link
|
|
171
|
-
// oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
|
|
172
|
-
// requirement; ohne den koennte der User mit Login-Sessions trotz
|
|
173
|
-
// Restriction-Flag durchschreiben.
|
|
174
|
-
//
|
|
175
|
-
// DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
|
|
176
|
-
// (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
|
|
177
|
-
// distinct error ist hier safe.
|
|
178
|
-
if (found.status === USER_STATUS.Restricted) {
|
|
179
|
-
return accountRestricted();
|
|
180
|
-
}
|
|
181
|
-
if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
|
|
182
|
-
return invalidCredentials();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Resolve tenant + roles via the tenant feature's memberships query.
|
|
186
|
-
// Returns [] if the user has no memberships — MVP: no login without an
|
|
187
|
-
// invitation, so we refuse with a dedicated error.
|
|
188
|
-
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
189
|
-
userId: found.id,
|
|
190
|
-
})) as Array<{ tenantId: TenantId; roles: readonly string[] }>; // @cast-boundary db-runner
|
|
272
|
+
const passwordGate = await gateVerifyPassword(
|
|
273
|
+
ctx,
|
|
274
|
+
found,
|
|
275
|
+
event.payload.password,
|
|
276
|
+
maxFailedAttempts,
|
|
277
|
+
lockoutDurationMinutes,
|
|
278
|
+
);
|
|
279
|
+
if (!passwordGate.ok) return passwordGate.result;
|
|
191
280
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
281
|
+
const emailGate = gateEnforceEmailVerified(found, strictVerification);
|
|
282
|
+
if (!emailGate.ok) return emailGate.result;
|
|
195
283
|
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
|
|
199
|
-
: undefined;
|
|
200
|
-
const chosen = preferred ?? memberships[0];
|
|
201
|
-
if (!chosen) {
|
|
202
|
-
return noMembership();
|
|
203
|
-
}
|
|
284
|
+
const statusGate = gateEnforceAccountStatus(found);
|
|
285
|
+
if (!statusGate.ok) return statusGate.result;
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// membership. Dedupe via Set damit eine Rolle die in beiden Quellen
|
|
209
|
-
// steht nicht doppelt im Session-Roles landet.
|
|
210
|
-
//
|
|
211
|
-
// Computed BEFORE the MFA gate (moved up from its original spot below
|
|
212
|
-
// baseSession) because the gate's "admins" enforcement policy needs
|
|
213
|
-
// the MERGED set — a SystemAdmin whose admin-ness lives only in
|
|
214
|
-
// globalRoles would be missed if only chosen.roles were passed.
|
|
215
|
-
const globalRoles = parseRoles(found.roles ?? null);
|
|
216
|
-
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
217
|
-
// only (globalRoles keeps SystemAdmin) — read-time backstop against a
|
|
218
|
-
// rebuild-resurrected role.
|
|
219
|
-
const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
|
|
287
|
+
const membershipGate = await gateResolveMembership(ctx, systemUser, found);
|
|
288
|
+
if (!membershipGate.ok) return membershipGate.result;
|
|
289
|
+
const { chosen, mergedRoles } = membershipGate.value;
|
|
220
290
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// login); policy demands MFA but the user never enrolled → block with
|
|
226
|
-
// mfa-setup-required (no session, no challenge — see auth-mfa's
|
|
227
|
-
// config.ts for why this deliberately hard-blocks); neither → proceed.
|
|
228
|
-
if (opts.mfaStatusChecker) {
|
|
229
|
-
const mfaStatus = await opts.mfaStatusChecker(ctx, found.id, chosen.tenantId, mergedRoles);
|
|
230
|
-
if ("challengeToken" in mfaStatus) {
|
|
231
|
-
return {
|
|
232
|
-
isSuccess: true,
|
|
233
|
-
data: { kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken },
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
if ("setupRequired" in mfaStatus) {
|
|
237
|
-
return {
|
|
238
|
-
isSuccess: true,
|
|
239
|
-
data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
|
|
240
|
-
};
|
|
241
|
-
}
|
|
291
|
+
const mfaGate = await gateEnforceMfa(ctx, opts, found.id, chosen.tenantId, mergedRoles);
|
|
292
|
+
if (!mfaGate.ok) return mfaGate.result;
|
|
293
|
+
if (mfaGate.value !== undefined) {
|
|
294
|
+
return { isSuccess: true, data: mfaGate.value };
|
|
242
295
|
}
|
|
243
296
|
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
roles: mergedRoles,
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
// Features can contribute identity facts (team IDs, feature flags, ...)
|
|
251
|
-
// via r.authClaims(). ctx.resolveAuthClaims is a thin pass-through to
|
|
252
|
-
// dispatcher.resolveAuthClaims — same impl also used by the switch-tenant
|
|
253
|
-
// route, so login + tenant-switch stay in sync.
|
|
254
|
-
//
|
|
255
|
-
// Best-effort: if no feature registered a hook, we get an empty record
|
|
256
|
-
// back and simply omit the `claims` field from the session (keeps the
|
|
257
|
-
// shape clean for the JWT layer, which already spreads claims
|
|
258
|
-
// conditionally based on presence).
|
|
259
|
-
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
260
|
-
const session: SessionUser =
|
|
261
|
-
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
isSuccess: true,
|
|
265
|
-
data: { kind: "auth-session", session },
|
|
266
|
-
};
|
|
297
|
+
const sessionGate = await gateBuildSession(ctx, found.id, chosen.tenantId, mergedRoles);
|
|
298
|
+
if (!sessionGate.ok) return sessionGate.result;
|
|
299
|
+
return { isSuccess: true, data: sessionGate.value };
|
|
267
300
|
},
|
|
268
301
|
});
|
|
269
302
|
}
|
|
@@ -84,10 +84,16 @@ afterAll(async () => {
|
|
|
84
84
|
await stack.cleanup();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
type RawUserMfaRow = {
|
|
87
|
+
type RawUserMfaRow = {
|
|
88
|
+
id: string;
|
|
89
|
+
userId: string;
|
|
90
|
+
totpSecret: string;
|
|
91
|
+
recoveryCodes: string;
|
|
92
|
+
version: number;
|
|
93
|
+
};
|
|
88
94
|
|
|
89
|
-
async function readRawRow(): Promise<RawUserMfaRow> {
|
|
90
|
-
const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, {});
|
|
95
|
+
async function readRawRow(userId?: string): Promise<RawUserMfaRow> {
|
|
96
|
+
const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, userId ? { userId } : {});
|
|
91
97
|
const row = rows[0];
|
|
92
98
|
if (!row) throw new Error("no user-mfa row");
|
|
93
99
|
return row;
|
|
@@ -195,7 +201,7 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
|
|
|
195
201
|
user,
|
|
196
202
|
);
|
|
197
203
|
|
|
198
|
-
const beforeRow = await readRawRow();
|
|
204
|
+
const beforeRow = await readRawRow(user.id);
|
|
199
205
|
const garbage = "not-a-stored-envelope";
|
|
200
206
|
await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
|
|
201
207
|
garbage,
|
|
@@ -241,7 +247,76 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
|
|
|
241
247
|
);
|
|
242
248
|
expect(rejectedWarning).toBeDefined();
|
|
243
249
|
|
|
244
|
-
const afterRow = await readRawRow();
|
|
250
|
+
const afterRow = await readRawRow(user.id);
|
|
245
251
|
expect(afterRow.totpSecret).toBe(garbage);
|
|
246
252
|
});
|
|
253
|
+
|
|
254
|
+
test("a non-envelope recoveryCodes is counted failed, never alreadyCurrent, and is left untouched", async () => {
|
|
255
|
+
mutableProvider.replace(
|
|
256
|
+
createEnvMasterKeyProvider({
|
|
257
|
+
env: {
|
|
258
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
259
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const user = createTestUser({ id: 3, roles: ["User"] });
|
|
265
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
266
|
+
AuthMfaHandlers.enableStart,
|
|
267
|
+
{ accountLabel: "user-3@example.com" },
|
|
268
|
+
user,
|
|
269
|
+
);
|
|
270
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
271
|
+
const secret = base32Decode(secretParam);
|
|
272
|
+
await stack.http.writeOk(
|
|
273
|
+
AuthMfaHandlers.enableConfirm,
|
|
274
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
275
|
+
user,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const beforeRow = await readRawRow(user.id);
|
|
279
|
+
const garbage = "not-a-stored-envelope";
|
|
280
|
+
await asRawClient(stack.db).unsafe(
|
|
281
|
+
`UPDATE read_user_mfa SET recovery_codes = $1 WHERE id = $2`,
|
|
282
|
+
[garbage, beforeRow.id],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
type CapturedLog = { info: string[]; warn: string[] };
|
|
286
|
+
const captured: CapturedLog = { info: [], warn: [] };
|
|
287
|
+
const capturingLog = {
|
|
288
|
+
info: (msg: string) => {
|
|
289
|
+
captured.info.push(msg);
|
|
290
|
+
},
|
|
291
|
+
warn: (msg: string) => {
|
|
292
|
+
captured.warn.push(msg);
|
|
293
|
+
},
|
|
294
|
+
error: () => {},
|
|
295
|
+
debug: () => {},
|
|
296
|
+
child: () => capturingLog,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await mfaReencryptJob({}, {
|
|
300
|
+
db: stack.db,
|
|
301
|
+
registry: stack.registry,
|
|
302
|
+
masterKeyProvider: mutableProvider,
|
|
303
|
+
log: capturingLog,
|
|
304
|
+
} as AppContext);
|
|
305
|
+
|
|
306
|
+
const completeLine = captured.info.find((line) =>
|
|
307
|
+
line.includes("[auth-mfa:reencrypt] complete:"),
|
|
308
|
+
);
|
|
309
|
+
if (!completeLine) throw new Error("job did not log a completion summary");
|
|
310
|
+
const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
|
|
311
|
+
migrated: number;
|
|
312
|
+
failed: number;
|
|
313
|
+
alreadyCurrent: number;
|
|
314
|
+
};
|
|
315
|
+
expect(result.migrated).toBe(0);
|
|
316
|
+
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
317
|
+
expect(result.alreadyCurrent).toBe(0);
|
|
318
|
+
|
|
319
|
+
const afterRow = await readRawRow(user.id);
|
|
320
|
+
expect(afterRow.recoveryCodes).toBe(garbage);
|
|
321
|
+
});
|
|
247
322
|
});
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// parses the stored JSON.
|
|
6
6
|
//
|
|
7
7
|
// A row whose value isn't a current-cipher envelope (malformed JSON, or
|
|
8
|
-
// any pre-envelope format)
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// any pre-envelope format) is counted `failed` before any decrypt attempt
|
|
9
|
+
// and left untouched — migrateRow short-circuits on `unrecognized` instead
|
|
10
|
+
// of relying on cipher.decrypt to reject it. No legacy single-key path.
|
|
11
11
|
//
|
|
12
12
|
// Idempotent: a re-run skips rows already on the current version. Every
|
|
13
13
|
// write goes through the event-store executor (config values are
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { collectPiiSubjectFields } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import {
|
|
4
4
|
access,
|
|
5
5
|
createEntity,
|
|
@@ -164,9 +164,9 @@ export const seenMessageEntity = createEntity({
|
|
|
164
164
|
// Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
|
|
165
165
|
// Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
|
|
166
166
|
// Rationale).
|
|
167
|
-
export const syncCursorTable =
|
|
167
|
+
export const syncCursorTable = deriveEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
|
|
168
168
|
source: "unmanaged",
|
|
169
169
|
});
|
|
170
|
-
export const seenMessageTable =
|
|
170
|
+
export const seenMessageTable = deriveEntityTableMeta("mail-seen-message", seenMessageEntity, {
|
|
171
171
|
source: "unmanaged",
|
|
172
172
|
});
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
EXT_TOKEN_VERIFIER,
|
|
4
4
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
5
5
|
import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
6
|
-
import {
|
|
6
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
|
|
9
9
|
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
@@ -67,7 +67,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
67
67
|
// Direct-write store like store_user_sessions: create/revoke write it, the
|
|
68
68
|
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
69
69
|
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
70
|
-
r.storeTable(
|
|
70
|
+
r.storeTable(deriveEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
|
|
71
71
|
reason: "read_side.api_tokens_direct_write",
|
|
72
72
|
// create.write encrypts `name` via encryptForDirectWrite (#820).
|
|
73
73
|
piiEncryptedOnWrite: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
4
|
createEntity,
|
|
@@ -55,9 +55,9 @@ export const apiTokenEntity = createEntity({
|
|
|
55
55
|
indexes: [{ unique: true, columns: ["tokenHash"], name: "store_api_tokens_hash_unique" }],
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// deriveEntityTableMeta (not buildEntityTable): this is a direct-write store, so
|
|
59
59
|
// the table must be a WritableTable (post ES-write-brand #742) — same as
|
|
60
60
|
// sessions' userSessionTable. buildEntityTable is branded executor-only.
|
|
61
|
-
export const apiTokenTable =
|
|
61
|
+
export const apiTokenTable = deriveEntityTableMeta("api-token", apiTokenEntity, {
|
|
62
62
|
source: "unmanaged",
|
|
63
63
|
});
|
|
@@ -42,7 +42,7 @@ import { SessionHandlers, SessionQueries } from "../constants";
|
|
|
42
42
|
import { createSessionsFeature } from "../feature";
|
|
43
43
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
44
44
|
import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
|
|
45
|
-
import { SESSION_REVOKED_EVENT_QN,
|
|
45
|
+
import { SESSION_REVOKED_EVENT_QN, sessionRevokedSchema } from "../session-revoked-event";
|
|
46
46
|
import { sessionCallbacksFromLateBound } from "../testing";
|
|
47
47
|
import { makeSessionHelpers } from "./test-helpers";
|
|
48
48
|
|
|
@@ -216,7 +216,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
216
216
|
|
|
217
217
|
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
218
218
|
expect(events).toHaveLength(1);
|
|
219
|
-
const payload = events[0]?.["payload"]
|
|
219
|
+
const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
|
|
220
220
|
expect(payload.userId).toBe(userId);
|
|
221
221
|
expect(payload.sessionIds).toEqual([sid]);
|
|
222
222
|
});
|
|
@@ -243,6 +243,22 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
243
243
|
expect(events).toHaveLength(1); // only the first, successful revoke
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
test("revoke-all-others with no other live sessions emits no event", async () => {
|
|
247
|
+
await h.seedUser("noop-others@example.com", "pw-long-enough");
|
|
248
|
+
const only = await h.login("noop-others@example.com", "pw-long-enough");
|
|
249
|
+
|
|
250
|
+
const res = await h.authedPost("/api/write", only.token, {
|
|
251
|
+
type: SessionHandlers.revokeAllOthers,
|
|
252
|
+
payload: {},
|
|
253
|
+
});
|
|
254
|
+
expect(res.status).toBe(200);
|
|
255
|
+
const body = (await res.json()) as { data: { count: number } };
|
|
256
|
+
expect(body.data.count).toBe(0);
|
|
257
|
+
|
|
258
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
259
|
+
expect(events).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
246
262
|
test("revoke-all-others appends one event listing every revoked sid", async () => {
|
|
247
263
|
const { userId } = await h.seedUser("event3@example.com", "pw-long-enough");
|
|
248
264
|
const a = await h.login("event3@example.com", "pw-long-enough");
|
|
@@ -257,7 +273,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
257
273
|
|
|
258
274
|
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
259
275
|
expect(events).toHaveLength(1);
|
|
260
|
-
const payload = events[0]?.["payload"]
|
|
276
|
+
const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
|
|
261
277
|
expect(payload.userId).toBe(userId);
|
|
262
278
|
expect(new Set(payload.sessionIds)).toEqual(new Set([a.sid, c.sid]));
|
|
263
279
|
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
type SessionStore,
|
|
4
4
|
type SessionStoreProvider,
|
|
5
5
|
} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
6
|
-
import {
|
|
6
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import {
|
|
8
8
|
access,
|
|
9
9
|
defineFeature,
|
|
@@ -111,11 +111,14 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
111
111
|
// (#498/#494). r.storeTable keeps the migration DDL but opts the
|
|
112
112
|
// table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
|
|
113
113
|
// which are direct-write stores too.
|
|
114
|
-
r.storeTable(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
r.storeTable(
|
|
115
|
+
deriveEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }),
|
|
116
|
+
{
|
|
117
|
+
reason: "read_side.user_sessions_direct_write",
|
|
118
|
+
// sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
|
|
119
|
+
piiEncryptedOnWrite: true,
|
|
120
|
+
},
|
|
121
|
+
);
|
|
119
122
|
|
|
120
123
|
// Self-registers as auth-foundation's sessionStore provider (#1371) —
|
|
121
124
|
// wraps the same createSessionCallbacks() used by the manual
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
4
|
createEntity,
|
|
@@ -69,11 +69,10 @@ export const userSessionEntity = createEntity({
|
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
// Plain EntityTableMeta, NOT a branded EntityTable: user-session is an
|
|
72
|
-
// unmanaged direct-write store (r.
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity, {
|
|
72
|
+
// unmanaged direct-write store (r.storeTable in feature.ts, no event stream —
|
|
73
|
+
// revocation is a column write, not an aggregate). Handlers write via ctx.db;
|
|
74
|
+
// the meta carries no executor-only brand so those writes stay legal. See
|
|
75
|
+
// feature.ts for the rebuild-exclusion rationale (#494/#498).
|
|
76
|
+
export const userSessionTable = deriveEntityTableMeta("user-session", userSessionEntity, {
|
|
78
77
|
source: "unmanaged",
|
|
79
78
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// of a hard-coded EUR literal.
|
|
1
|
+
// Package-level cases the recipe does NOT cover: unknown field at define time,
|
|
2
|
+
// and missing tenant-settings mount fails loud when currency is omitted.
|
|
4
3
|
|
|
5
|
-
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
5
|
import {
|
|
7
6
|
configValuesTable,
|
|
8
7
|
createConfigAccessorFactory,
|
|
@@ -26,7 +25,6 @@ import {
|
|
|
26
25
|
unsafeCreateEntityTable,
|
|
27
26
|
unsafePushTables,
|
|
28
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
-
import { createTenantSettingsFeature } from "../feature";
|
|
30
28
|
import { defineCreateWithTenantDefaults } from "../tenant-defaults";
|
|
31
29
|
|
|
32
30
|
const invoiceEntity = createEntity({
|
|
@@ -40,76 +38,57 @@ const invoiceEntity = createEntity({
|
|
|
40
38
|
|
|
41
39
|
const ACCESS = { roles: ["Admin"] } as const;
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
r.writeHandler(
|
|
41
|
+
test("defineCreateWithTenantDefaults throws on an unknown currency field", () => {
|
|
42
|
+
expect(() =>
|
|
46
43
|
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
47
44
|
access: ACCESS,
|
|
48
|
-
currencyFields: ["
|
|
49
|
-
localeField: "language",
|
|
45
|
+
currencyFields: ["notARealField"],
|
|
50
46
|
}),
|
|
51
|
-
);
|
|
47
|
+
).toThrow(/unknown field "notARealField"/);
|
|
52
48
|
});
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
configResolver: resolver,
|
|
65
|
-
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
66
|
-
}),
|
|
50
|
+
describe("without tenant-settings mount", () => {
|
|
51
|
+
const invoiceFeature = defineFeature("invoice", (r) => {
|
|
52
|
+
r.entity("invoice", invoiceEntity);
|
|
53
|
+
r.writeHandler(
|
|
54
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
55
|
+
access: ACCESS,
|
|
56
|
+
currencyFields: ["amount"],
|
|
57
|
+
localeField: "language",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
67
60
|
});
|
|
68
|
-
await unsafePushTables(stack.db, { configValuesTable });
|
|
69
|
-
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
70
|
-
await createEventsTable(stack.db);
|
|
71
|
-
await pushEntityProjectionTables(stack, stack.registry);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterAll(async () => {
|
|
75
|
-
await stack.cleanup();
|
|
76
|
-
});
|
|
77
61
|
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
}>("invoice:write:invoice:create", { title: "Rechnung 1", amount: { amount: 1000 } }, admin);
|
|
82
|
-
expect(invoice.data.amount).toEqual({ amount: 1000, currency: "EUR" });
|
|
83
|
-
expect(invoice.data.language).toBe("en");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("Tenant setzt CHF/de — neue Entity ohne explizite Werte übernimmt die Tenant-Config, nicht das Feature-Default", async () => {
|
|
87
|
-
await stack.http.writeOk(
|
|
88
|
-
"config:write:set",
|
|
89
|
-
{ key: "tenant-settings:config:currency", value: "CHF" },
|
|
90
|
-
admin,
|
|
91
|
-
);
|
|
92
|
-
await stack.http.writeOk(
|
|
93
|
-
"config:write:set",
|
|
94
|
-
{ key: "tenant-settings:config:locale", value: "de" },
|
|
95
|
-
admin,
|
|
96
|
-
);
|
|
62
|
+
const tenantId = testTenantId(1);
|
|
63
|
+
const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
|
|
64
|
+
let stack: TestStack;
|
|
97
65
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
const resolver = createConfigResolver();
|
|
68
|
+
// config feature only — no createTenantSettingsFeature()
|
|
69
|
+
stack = await setupTestStack({
|
|
70
|
+
features: [createConfigFeature(), invoiceFeature],
|
|
71
|
+
extraContext: ({ registry }) => ({
|
|
72
|
+
configResolver: resolver,
|
|
73
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
78
|
+
await createEventsTable(stack.db);
|
|
79
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
80
|
+
});
|
|
101
81
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await stack.cleanup();
|
|
84
|
+
});
|
|
105
85
|
|
|
106
|
-
test("
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
114
|
-
expect(invoice.data.amount).toEqual({ amount: 3000, currency: "USD" });
|
|
86
|
+
test("omitting money.currency fails loud (no silent feature-default fill)", async () => {
|
|
87
|
+
const err = await stack.http.writeErr(
|
|
88
|
+
"invoice:write:invoice:create",
|
|
89
|
+
{ title: "Invoice", amount: { amount: 1000 } },
|
|
90
|
+
admin,
|
|
91
|
+
);
|
|
92
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
93
|
+
});
|
|
115
94
|
});
|
|
@@ -521,6 +521,19 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
521
521
|
const body = (await res.json()) as { error?: string };
|
|
522
522
|
expect(body.error).toBe("missing_token");
|
|
523
523
|
});
|
|
524
|
+
|
|
525
|
+
test("POST-Exchange: invalid token → 404 + download.notFound i18nKey", async () => {
|
|
526
|
+
const res = await stack.app.fetch(
|
|
527
|
+
new Request("http://test/user-export/by-token", {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers: { "content-type": "application/json" },
|
|
530
|
+
body: JSON.stringify({ token: "fake-xxxxx" }),
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
expect(res.status).toBe(404);
|
|
534
|
+
const body = (await res.json()) as { error: { i18nKey: string } };
|
|
535
|
+
expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
|
|
536
|
+
});
|
|
524
537
|
});
|
|
525
538
|
|
|
526
539
|
describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
@@ -47,7 +47,7 @@ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-
|
|
|
47
47
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
48
48
|
import {
|
|
49
49
|
SESSION_REVOKED_EVENT_QN,
|
|
50
|
-
|
|
50
|
+
sessionRevokedSchema,
|
|
51
51
|
} from "../../sessions/session-revoked-event";
|
|
52
52
|
import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
|
|
53
53
|
import { hashPassword } from "../../shared";
|
|
@@ -415,6 +415,26 @@ describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () =>
|
|
|
415
415
|
});
|
|
416
416
|
|
|
417
417
|
describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
418
|
+
test("Privileged revoke-all-for-user with no live sessions emits no event", async () => {
|
|
419
|
+
const { userId } = await seedAliceWithMembership();
|
|
420
|
+
const systemUser = await mintActor({
|
|
421
|
+
id: "00000000-0000-4000-8000-000000000000",
|
|
422
|
+
tenantId: TENANT,
|
|
423
|
+
roles: ["SystemAdmin"],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await stack.http.writeOk<{ count: number; userId: string }>(
|
|
427
|
+
SessionHandlers.revokeAllForUser,
|
|
428
|
+
{ userId },
|
|
429
|
+
systemUser,
|
|
430
|
+
);
|
|
431
|
+
expect(result.count).toBe(0);
|
|
432
|
+
expect(result.userId).toBe(userId);
|
|
433
|
+
|
|
434
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
435
|
+
expect(events).toHaveLength(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
418
438
|
test("Privileged-Caller revoked alle live sessions eines Users", async () => {
|
|
419
439
|
const { userId } = await seedAliceWithMembership();
|
|
420
440
|
// 2 Sessions erzeugen via Login + zweiter Login.
|
|
@@ -454,7 +474,7 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
|
454
474
|
// target user's session tenant). One event, both sids listed.
|
|
455
475
|
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
456
476
|
expect(events).toHaveLength(1);
|
|
457
|
-
const payload = events[0]?.["payload"]
|
|
477
|
+
const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
|
|
458
478
|
expect(payload.userId).toBe(userId);
|
|
459
479
|
expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
|
|
460
480
|
});
|
|
@@ -26,8 +26,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
26
26
|
// "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
|
|
27
27
|
// from tenant A could unrestrict/reactivate a user who has never been a
|
|
28
28
|
// member of tenant A. Only SystemAdmin (platform-wide) skips the check;
|
|
29
|
-
//
|
|
30
|
-
// tenantId.
|
|
29
|
+
// the target must have a membership row in the acting admin's
|
|
30
|
+
// `event.user.tenantId` (row existence only — no active-status field).
|
|
31
31
|
//
|
|
32
32
|
// State-Transitions:
|
|
33
33
|
// Restricted → Active ✓
|
|
@@ -34,8 +34,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
34
34
|
// user-data-rights.md "Cross-Tenant-Semantik"). Without a membership
|
|
35
35
|
// check, a TenantAdmin from tenant A could restrict/unrestrict a user who
|
|
36
36
|
// has never been a member of tenant A. Only SystemAdmin (platform-wide)
|
|
37
|
-
// skips the check;
|
|
38
|
-
//
|
|
37
|
+
// skips the check; the target must have a membership row in the acting
|
|
38
|
+
// admin's `event.user.tenantId` (row existence only — no active-status field).
|
|
39
39
|
//
|
|
40
40
|
// State-Transitions:
|
|
41
41
|
// Active → Restricted ✓ (dieser Handler)
|