-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathadmin-user-endpoints.ts
More file actions
561 lines (519 loc) · 21.3 KB
/
Copy pathadmin-user-endpoints.ts
File metadata and controls
561 lines (519 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Admin "direct user management" endpoints (#2766 V1).
*
* `sys_user` is `managedBy:'better-auth'` — generic CRUD is suppressed because
* a plain ObjectQL insert would bypass better-auth's password hashing and never
* create the `sys_account` credential row, producing a user that can never sign
* in. Until now the only way to add a teammate was the `invite_user` action,
* which hard-depends on a wired EmailService. These endpoints give platform
* admins a first-class, email-independent path:
*
* POST /api/v1/auth/admin/create-user — create a login-capable account
* POST /api/v1/auth/admin/set-user-password — (re)set a password + force change
*
* Both are wrapped as pure `runXxx(deps, request)` handlers (the
* set-initial-password.ts pattern) so the HTTP mount in auth-plugin.ts stays a
* thin shell and the logic is unit-testable with a mocked deps surface.
*
* Authorization: the HTTP route owns the ADR-0068 platform-admin gate
* (isPlatformAdmin / positions / legacy role scalar). `createUser` is then
* invoked WITHOUT request headers — better-auth treats a header-less server
* call as trusted and skips its own `role === 'admin'` check, which under
* ADR-0068 no longer matches every platform admin. set-user-password
* re-implements the native handler's core (hash + credential-account upsert)
* through `$context` for the same reason: the stock endpoint's adminMiddleware
* would 403 a platform admin whose legacy `role` scalar was never synthesized.
*
* Security red line: a generated temporary password exists ONLY in the HTTP
* response body (surfaced once via the action's resultDialog). It is never
* logged, never written to the audit payload, never persisted.
*/
/** Minimal better-auth server-api surface used by create-user. */
export interface AdminCreateCapableApi {
createUser(opts: {
body: {
email: string;
name: string;
password?: string;
role?: string | string[];
data?: Record<string, unknown>;
};
}): Promise<{ user?: { id?: string; email?: string; name?: string } } | null>;
}
/**
* Minimal better-auth `$context` surface used by set-user-password. Mirrors
* what the stock `/admin/set-user-password` handler touches, minus its
* role check (our route gates instead).
*/
export interface AuthContextLike {
password: {
hash(pw: string): Promise<string>;
config: { minPasswordLength: number; maxPasswordLength: number };
};
internalAdapter: {
findUserById(id: string): Promise<unknown | null>;
findAccounts(userId: string): Promise<Array<{ providerId?: string }>>;
updatePassword(userId: string, hash: string): Promise<unknown>;
createAccount(account: {
userId: string;
providerId: string;
accountId: string;
password: string;
}): Promise<unknown>;
};
}
export interface AdminUserEndpointDeps {
getAuthApi(): Promise<AdminCreateCapableApi>;
getAuthContext(): Promise<AuthContextLike>;
/** ObjectQL engine (may be undefined when the data plugin isn't wired). */
getDataEngine(): AdminUserDataEngine | undefined;
/** ADR-0069 D1 class-mix validator; throws `{ code, message }` on violation. */
assertPasswordComplexity(pw: string): Promise<void>;
/**
* Prime the process-local "someone must change their password" gate cache
* so the authGate activates immediately on this node (other nodes catch up
* on the cache TTL). Best-effort.
*/
noteMustChangePasswordIssued(): void;
/** Is the better-auth phoneNumber plugin wired (#2766 V1.5)? */
phoneNumberEnabled?(): boolean;
/**
* ADR-0093 D3 — accessor for the `tenancy` service. When present, the
* create-user membership bind resolves its target org through
* `tenancy.defaultOrgId()` (single → default org; MULTI → null, never
* guess). Without it the bind falls back to `resolveDefaultOrgId`, which
* prefers the `slug='default'` org — correct single-org, but in multi-org
* (where a bootstrap default org coexists with real tenants) it would
* mis-bind new users into the default org. Wire this everywhere tenancy
* is registered.
*/
getTenancy?(): { defaultOrgId(): Promise<string | null> } | undefined;
logger?: { warn(msg: string): void };
}
export interface AdminUserDataEngine {
update(object: string, doc: Record<string, unknown>, opts?: unknown): Promise<unknown>;
insert(object: string, doc: Record<string, unknown>, opts?: unknown): Promise<unknown>;
/**
* Optional read surface — used to resolve the sole organization and to keep
* the membership bind idempotent. Absent on lean mocks / when the data plugin
* isn't wired, in which case the org bind simply no-ops.
*/
find?(object: string, query?: unknown, opts?: unknown): Promise<unknown>;
}
/** The gated caller, passed by the route after its ADR-0068 check. */
export interface AdminActor {
id: string;
email?: string;
}
export interface EndpointResult {
status: number;
body: {
success: boolean;
data?: Record<string, unknown>;
error?: { code: string; message: string };
};
}
import { generatePlaceholderEmail } from './placeholder-email.js';
import { reconcileMembership } from './reconcile-membership.js';
import { resolveDefaultOrgId } from './tenancy-service.js';
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
// ── Temporary password generation ────────────────────────────────────────
//
// Must satisfy the ADR-0069 class-mix policy (lower + upper + digit + symbol)
// and the better-auth min length regardless of deployment config, so one
// guaranteed character per class + random fill. Ambiguous glyphs (O/0, l/1)
// are excluded — these passwords are read off a dialog and typed once.
const LOWER = 'abcdefghijkmnopqrstuvwxyz';
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const DIGIT = '23456789';
const SYMBOL = '!@#$%^&*-_=+';
const ALL = LOWER + UPPER + DIGIT + SYMBOL;
export function generateTemporaryPassword(length = 16): string {
const size = Math.max(length, 12);
const bytes = new Uint32Array(size);
globalThis.crypto.getRandomValues(bytes);
const pick = (set: string, rnd: number) => set[rnd % set.length];
const chars: string[] = [
pick(LOWER, bytes[0]),
pick(UPPER, bytes[1]),
pick(DIGIT, bytes[2]),
pick(SYMBOL, bytes[3]),
];
for (let i = 4; i < size; i++) chars.push(pick(ALL, bytes[i]));
// Fisher–Yates with fresh randomness so the guaranteed classes aren't
// always in the first four positions.
const shuffle = new Uint32Array(size);
globalThis.crypto.getRandomValues(shuffle);
for (let i = size - 1; i > 0; i--) {
const j = shuffle[i] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
// ── Shared helpers ───────────────────────────────────────────────────────
async function parseJson(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function badRequest(message: string): EndpointResult {
return { status: 400, body: { success: false, error: { code: 'invalid_request', message } } };
}
/**
* Linear-time email plausibility check (local@domain with a dotted domain).
* Deliberately not a regex: the obvious `\S+@\S+\.\S+` is polynomially
* backtrackable on adversarial input (CodeQL js/polynomial-redos) since `\S`
* overlaps with `@` and `.`. better-auth revalidates on its own side; this
* only gates obviously-broken rows early.
*/
export function isLikelyEmail(value: string): boolean {
if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false;
const at = value.indexOf('@');
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false;
const domain = value.slice(at + 1);
const dot = domain.lastIndexOf('.');
return dot > 0 && dot < domain.length - 1;
}
/**
* Resolve the password for a create/set request: an explicit `password` /
* `newPassword`, or a generated temporary one when `generatePassword` is true.
* Returns an EndpointResult on validation failure.
*/
async function resolvePassword(
deps: Pick<AdminUserEndpointDeps, 'assertPasswordComplexity'>,
body: Record<string, unknown>,
explicitKey: 'password' | 'newPassword',
): Promise<{ password: string; generated: boolean } | EndpointResult> {
const explicit = body[explicitKey];
const generate = body.generatePassword === true;
if (generate && explicit !== undefined) {
return badRequest(`Provide either ${explicitKey} or generatePassword, not both`);
}
if (generate) return { password: generateTemporaryPassword(), generated: true };
if (typeof explicit !== 'string' || explicit.length === 0) {
return badRequest(`${explicitKey} is required (or set generatePassword: true)`);
}
try {
await deps.assertPasswordComplexity(explicit);
} catch (error) {
const e = error as { code?: string; message?: string } | null;
return {
status: 400,
body: {
success: false,
error: {
code: e?.code ?? 'PASSWORD_POLICY_VIOLATION',
message: e?.message ?? 'Password does not meet the complexity policy',
},
},
};
}
return { password: explicit, generated: false };
}
/**
* Stamp `sys_user.must_change_password`. Best-effort — the flag drives a
* fail-open gate, so a failed stamp must not fail the whole operation; we
* surface the *actual* stamped state in the response instead.
*/
async function stampMustChangePassword(
deps: AdminUserEndpointDeps,
userId: string,
value: boolean,
): Promise<boolean> {
const engine = deps.getDataEngine();
if (!engine) return false;
try {
await engine.update('sys_user', { id: userId, must_change_password: value }, {
context: SYSTEM_CTX,
});
if (value) deps.noteMustChangePasswordIssued();
return true;
} catch (error) {
deps.logger?.warn(
`[AuthPlugin] failed to stamp must_change_password for user ${userId}: ${
(error as Error)?.message ?? error
}`,
);
return false;
}
}
/**
* Bind an admin-created user to the organization (single-org membership).
*
* ADR-0093 D2 — this now delegates to the shared membership reconciler, the
* single owner of the "every new user gets a membership" invariant. The
* reconciler ALSO runs as a `user.create.after` hook (covering signup / import /
* SSO JIT); this endpoint-side call is retained as belt-and-suspenders for the
* admin create path until the hook's coverage is verified in integration — both
* are idempotent and yield to any existing membership, so double-coverage never
* double-binds (ADR-0093 D2 "interim double-coverage is harmless"). The target
* org is the single-org default (resolveDefaultOrgId); multi-org resolves to
* none, so this no-ops there just as before.
*
* Returns the shape the response/audit consumed pre-ADR-0093:
* `membershipCreated` is true only when THIS call inserted the row (a `bound`
* outcome); a `yielded` outcome (the hook or a race already bound it) reports
* the org with `membershipCreated: false`.
*/
async function bindUserToSoleOrganization(
deps: AdminUserEndpointDeps,
userId: string,
): Promise<{ organizationId: string | null; membershipCreated: boolean }> {
const engine = deps.getDataEngine();
// ADR-0093 D3 — mode-aware target resolution. The tenancy service returns
// null in multi mode (the framework never guesses a tenant), which also
// keeps this endpoint bind from grabbing the bootstrap `slug='default'` org
// in a multi-org deployment. Fallback (no tenancy wired: lean embeddings,
// legacy mocks) keeps the single-org resolution.
const tenancy = deps.getTenancy?.();
const result = await reconcileMembership(engine, userId, {
policy: 'auto',
resolveTargetOrg: () => (tenancy ? tenancy.defaultOrgId() : resolveDefaultOrgId(engine)),
logger: deps.logger
? { warn: (msg, meta) => deps.logger?.warn(`${msg} ${meta ? JSON.stringify(meta) : ''}`.trim()) }
: undefined,
});
return {
organizationId: result.organizationId ?? null,
membershipCreated: result.outcome === 'bound',
};
}
/**
* Best-effort explicit audit row. better-auth writes bypass the ObjectQL
* lifecycle hooks that plugin-audit subscribes to, so admin identity
* operations would otherwise leave no compliance trail. Never throws; never
* includes password material (red line).
*/
async function writeAdminAudit(
deps: AdminUserEndpointDeps,
entry: {
action: 'create' | 'update';
actor: AdminActor;
recordId: string;
metadata: Record<string, unknown>;
},
): Promise<void> {
const engine = deps.getDataEngine();
if (!engine) return;
try {
await engine.insert(
'sys_audit_log',
{
action: entry.action,
user_id: entry.actor.id,
actor: entry.actor.id,
object_name: 'sys_user',
record_id: entry.recordId,
metadata: JSON.stringify(entry.metadata),
},
{ context: SYSTEM_CTX },
);
} catch {
// plugin-audit may not be installed (no sys_audit_log table) — audit is
// best-effort by design here; the operation itself must not fail.
}
}
function mapAuthApiError(error: unknown, fallback: string): EndpointResult {
const e = error as {
statusCode?: number;
status?: number | string;
body?: { code?: string; message?: string };
message?: string;
} | null;
const code = e?.body?.code ?? 'internal';
const message = e?.body?.message ?? e?.message ?? fallback;
const rawStatus =
typeof e?.statusCode === 'number' ? e.statusCode : typeof e?.status === 'number' ? e.status : 500;
const status = code === 'USER_ALREADY_EXISTS' ? 409 : rawStatus;
return { status, body: { success: false, error: { code, message } } };
}
// ── POST /admin/create-user ──────────────────────────────────────────────
export async function runAdminCreateUser(
deps: AdminUserEndpointDeps,
request: Request,
actor: AdminActor,
): Promise<EndpointResult> {
const body = await parseJson(request);
// #2766 V1.5 — identity: a real email, a phone number, or both. Phone-only
// users get a generated placeholder address (better-auth requires a unique
// email) that every mail callback refuses to send to.
const rawEmail = body.email;
const hasEmail = typeof rawEmail === 'string' && rawEmail.trim().length > 0;
if (hasEmail && !isLikelyEmail((rawEmail as string).trim())) {
return badRequest('A valid email is required');
}
const rawPhone = body.phoneNumber ?? body.phone_number;
const hasPhone = typeof rawPhone === 'string' && rawPhone.trim().length > 0;
let phoneNumber: string | undefined;
if (hasPhone) {
if (!deps.phoneNumberEnabled?.()) {
return badRequest('Phone numbers require the phoneNumber auth plugin (auth.plugins.phoneNumber)');
}
phoneNumber = normalizePhoneNumber(String(rawPhone));
if (!phoneNumber) {
return badRequest('phoneNumber must be a valid phone number (E.164 recommended, e.g. +8613800000000)');
}
}
if (!hasEmail && !phoneNumber) {
return badRequest('Either email or phoneNumber is required');
}
const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail();
const name =
typeof body.name === 'string' && body.name.trim().length > 0
? body.name.trim()
: hasEmail
? email.split('@')[0]
: (phoneNumber as string);
const role = typeof body.role === 'string' && body.role.length > 0 ? body.role : undefined;
const mustChangePassword = body.mustChangePassword !== false; // default true
const resolved = await resolvePassword(deps, body, 'password');
if ('status' in resolved) return resolved;
let created: { user?: { id?: string; email?: string; name?: string } } | null;
try {
const authApi = await deps.getAuthApi();
// Header-less server call: trusted, skips better-auth's role check — the
// HTTP route already ran the ADR-0068 platform-admin gate.
created = await authApi.createUser({
body: {
email: email.toLowerCase(),
name,
password: resolved.password,
...(role ? { role } : {}),
// phoneNumber is a user-model field contributed by the phoneNumber
// plugin's schema; `data` is better-auth's carrier for such fields.
...(phoneNumber ? { data: { phoneNumber } } : {}),
},
});
} catch (error) {
return mapAuthApiError(error, 'create-user failed');
}
const userId = created?.user?.id;
if (typeof userId !== 'string' || userId.length === 0) {
return {
status: 500,
body: { success: false, error: { code: 'internal', message: 'better-auth returned no user id' } },
};
}
const stamped = mustChangePassword
? await stampMustChangePassword(deps, userId, true)
: false;
// Match the invite / add-member flows: give the new user a membership so a
// single-org deployment shows them under the Default Organization instead of
// as a member-less account. No-op in multi-org (≥2 orgs) — see the helper.
const membership = await bindUserToSoleOrganization(deps, userId);
await writeAdminAudit(deps, {
action: 'create',
actor,
recordId: userId,
metadata: {
event: 'user.admin_created',
email: email.toLowerCase(),
...(phoneNumber ? { phoneNumber } : {}),
...(role ? { role } : {}),
placeholderEmail: !hasEmail,
passwordGenerated: resolved.generated,
mustChangePassword: stamped,
...(membership.organizationId ? { organizationId: membership.organizationId } : {}),
membershipCreated: membership.membershipCreated,
},
});
return {
status: 200,
body: {
success: true,
data: {
user: {
id: userId,
email: created?.user?.email ?? email.toLowerCase(),
name,
...(phoneNumber ? { phoneNumber } : {}),
},
placeholderEmail: !hasEmail,
mustChangePassword: stamped,
...(membership.organizationId ? { organizationId: membership.organizationId } : {}),
membershipCreated: membership.membershipCreated,
...(resolved.generated ? { temporaryPassword: resolved.password } : {}),
},
},
};
}
/**
* Light phone normalization: strip separators, require 6–15 digits with an
* optional leading `+` (E.164 recommended). Returns undefined when invalid.
*/
export function normalizePhoneNumber(raw: string): string | undefined {
const stripped = raw.replace(/[\s\-().]/g, '');
return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined;
}
// ── POST /admin/set-user-password ────────────────────────────────────────
export async function runAdminSetUserPassword(
deps: AdminUserEndpointDeps,
request: Request,
actor: AdminActor,
): Promise<EndpointResult> {
const body = await parseJson(request);
const userId = body.userId ?? body.user_id;
if (typeof userId !== 'string' || userId.length === 0) {
return badRequest('userId is required');
}
const mustChangePassword = body.mustChangePassword !== false; // default true
const resolved = await resolvePassword(deps, body, 'newPassword');
if ('status' in resolved) return resolved;
try {
const authCtx = await deps.getAuthContext();
const { minPasswordLength, maxPasswordLength } = authCtx.password.config;
if (resolved.password.length < minPasswordLength) {
return badRequest(`Password must be at least ${minPasswordLength} characters`);
}
if (resolved.password.length > maxPasswordLength) {
return badRequest(`Password must be at most ${maxPasswordLength} characters`);
}
if (!(await authCtx.internalAdapter.findUserById(userId))) {
return { status: 404, body: { success: false, error: { code: 'not_found', message: 'User not found' } } };
}
// Mirrors the stock /admin/set-user-password core: hash, then update the
// credential account or create one for SSO/invite-onboarded users.
const hashed = await authCtx.password.hash(resolved.password);
const accounts = await authCtx.internalAdapter.findAccounts(userId);
if (accounts.find((a) => a?.providerId === 'credential')) {
await authCtx.internalAdapter.updatePassword(userId, hashed);
} else {
await authCtx.internalAdapter.createAccount({
userId,
providerId: 'credential',
accountId: userId,
password: hashed,
});
}
} catch (error) {
return mapAuthApiError(error, 'set-user-password failed');
}
const stamped = mustChangePassword
? await stampMustChangePassword(deps, userId, true)
: await stampMustChangePassword(deps, userId, false);
await writeAdminAudit(deps, {
action: 'update',
actor,
recordId: userId,
metadata: {
event: 'user.admin_password_set',
passwordGenerated: resolved.generated,
mustChangePassword: mustChangePassword && stamped,
},
});
return {
status: 200,
body: {
success: true,
data: {
userId,
mustChangePassword: mustChangePassword && stamped,
...(resolved.generated ? { temporaryPassword: resolved.password } : {}),
},
},
};
}