Skip to content

Commit 2a71f48

Browse files
os-zhuangclaude
andauthored
feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入(#2766 V1/V1.5/V2) (#2771)
* feat(auth): admin direct user management + phone sign-in (#2766 V1/V1.5) V1 — platform admins can create login-capable accounts and (re)set passwords without the email-dependent invite flow: - POST /api/v1/auth/admin/create-user: ADR-0068 admin gate, then a trusted header-less auth.api.createUser call (better-auth pipeline: scrypt hash + credential sys_account). Optional generated temporary password, returned ONCE in the response (never logged / persisted / audited). - POST /api/v1/auth/admin/set-user-password: shadows the stock better-auth route, which only honors the legacy role scalar; mirrors its hash + credential-account core through $context under the ADR-0068 gate, adds generate-password and must-change stamping. - sys_user.must_change_password + computeAuthGate branch (reuses PASSWORD_EXPIRED so the existing 403 seam and Console redirect apply); cleared by stampPasswordChangedAt on any completed password change. Gate activation via a lazy-TTL "any flagged user" cache mirroring _orgMfaCache, primed synchronously on the issuing node. - create_user / upgraded set_user_password actions on sys_user (pure schema; resultDialog reveals the temporary password one time). - features.admin now mirrors enabled.admin (SCIM forces admin on, ADR-0071) instead of advertising false in SCIM deployments. - Explicit best-effort sys_audit_log rows (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to). V1.5 — better-auth phoneNumber plugin (opt-in auth.plugins.phoneNumber): - Phone+password sign-in only (POST /sign-in/phone-number); sendOTP throws NOT_SUPPORTED until SMS infrastructure exists (tracked separately). - sys_user.phone_number (unique) / phone_number_verified, snake_case via buildPhoneNumberPluginSchema(). - Phone-only accounts get a placeholder email (u-<random>@placeholder.invalid, RFC 2606 — never deliverable, never derived from the phone number so exports/logs can't leak it). Every mail callback (reset / invitation / magic link) refuses placeholder recipients with PLACEHOLDER_EMAIL. - Invitation/magic-link warn logs no longer print bearer URLs outside dev. Closes nothing yet — V2 (bulk import) follows on this branch. Ref #2766 #2758. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o * refactor(rest): extract prepareImportRequest + CSV/xlsx parsers to import-prepare.ts (#2766 V2a) Verbatim move out of rest-server.ts (module-private → exported) so the identity import endpoint in plugin-auth can accept payloads byte-identical to the generic /data/:object/import routes without re-implementing the parsers. rest-server.ts imports the extracted module; the two call sites and behavior are unchanged (import-integration tests as the regression gate). Also exports the import building blocks (runImport, coerceRow, buildFieldMetaMap, types) from the package index for cross-package reuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o * feat(auth): identity bulk import endpoint POST /admin/import-users (#2766 V2) Dedicated sys_user import (re-scoped #2758) — the generic /data import path writes through ObjectQL, bypassing better-auth hashing and never creating the sys_account credential row, so it stays closed for identity. This endpoint reuses the generic import FRAMEWORK (prepareImportRequest payload parsing: rows[]/csv/xlsxBase64 + mapping; runImport row engine: dryRun, upsert matching, row results) with an identity write protocol: - createData drives auth.api.createUser per row (scrypt + credential account); no bulk primitive on purpose. - passwordPolicy 'invite': rows must carry a real email (row-level INVITE_REQUIRES_EMAIL at validation/dryRun time; request-level 400 when no EmailService is wired); accounts are created with a throwaway password and a set-your-password reset email is requested per created row. Email failure marks the row INVITE_EMAIL_FAILED — created, not rolled back. - passwordPolicy 'temporary': generated password per row, returned ONCE in the response; must_change_password stamped (403 PASSWORD_EXPIRED gate from V1). Red-line tests assert no password reaches audit rows, logs, or any persisted surface. - mode insert|upsert, matchBy email|phone (phone requires the V1.5 phoneNumber plugin); upsert updates touch profile fields only (name/phone_number/role) — never email, never credentials, so a re-imported CSV can't silently reset existing users' passwords; blank match keys skip instead of creating duplicates. - Phone-only rows (temporary policy) get placeholder emails; imported password columns are ignored (pre-hashed migration is out of scope). - Sync only, <=500 rows/request (scrypt ~100ms/row); async:true returns ASYNC_NOT_SUPPORTED with batching guidance — an async job is a persistence surface and cannot carry temporary passwords; tracked as a follow-up. No undo by design (undo == bulk-deleting users). - Run-level sys_audit_log row (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to), password-free. plugin-auth now depends on @objectstack/rest (no cycle; rest does not depend on plugin-auth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o * chore: add changeset for #2766 V1/V1.5/V2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o * fix(auth): address CodeQL alerts — linear-time email check, drop unused import Replaces the /^\S+@\S+\.\S+$/ plausibility regex (polynomially backtrackable on adversarial input — js/polynomial-redos, alert 843) with a linear indexOf-based isLikelyEmail() shared by the create-user and import-users endpoints, with an adversarial-input regression test. Removes the unused beforeEach import (alert 844). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f517341 commit 2a71f48

19 files changed

Lines changed: 2698 additions & 332 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/spec": minor
6+
---
7+
8+
feat(auth): admin direct user management, phone sign-in, and identity bulk import (#2766, re-scoped #2758)
9+
10+
`sys_user` is managed by better-auth and its generic CRUD is suppressed, so
11+
until now the only way to add a teammate was the email-dependent invite flow.
12+
This ships three staged capabilities:
13+
14+
- **Admin direct user management**`POST /api/v1/auth/admin/create-user`
15+
and a wrapped `POST /api/v1/auth/admin/set-user-password` (ADR-0068
16+
platform-admin gate; better-auth pipeline so credentials are real). Optional
17+
generated temporary password (returned once, never persisted or logged) and
18+
a new `sys_user.must_change_password` flag enforced through the ADR-0069
19+
authGate (`403 PASSWORD_EXPIRED` until the user changes it). New
20+
`create_user` action and upgraded `set_user_password` action on the Users
21+
list — pure schema, no frontend changes.
22+
- **Phone sign-in (opt-in `auth.plugins.phoneNumber`)** — better-auth
23+
phoneNumber plugin, phone+password only (`POST /sign-in/phone-number`);
24+
OTP flows stay off until SMS infrastructure exists. Adds
25+
`sys_user.phone_number` (unique) / `phone_number_verified`. Phone-only
26+
accounts get an undeliverable placeholder email
27+
(`u-<random>@placeholder.invalid`, never derived from the phone number);
28+
all auth mail callbacks refuse placeholder recipients.
29+
- **Identity bulk import**`POST /api/v1/auth/admin/import-users` accepts
30+
the same payloads as the generic import routes (rows/csv/xlsx, dryRun,
31+
upsert by email or phone) but writes every row through better-auth.
32+
Password policies: `invite` (reset-link email per created user; requires an
33+
EmailService) and `temporary` (per-row one-time passwords + forced change).
34+
Sync only, ≤500 rows per request; no undo; upsert updates touch profile
35+
fields only and can never reset an existing user's password.
36+
`prepareImportRequest` and the CSV/xlsx parsers moved from rest-server.ts
37+
to an exported `import-prepare.ts` module (behavior unchanged).

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,20 +114,105 @@ export const SysUser = ObjectSchema.create({
114114
successMessage: 'Account unlocked',
115115
refreshAfter: true,
116116
},
117+
{
118+
// #2766 V1 — a platform admin can add a login-capable teammate without
119+
// the email-dependent invite flow. Hits the plugin-auth wrapper route
120+
// (NOT better-auth's stock /admin/create-user): the wrapper runs the
121+
// ADR-0068 admin gate, drives the better-auth pipeline (scrypt hash +
122+
// credential sys_account), stamps must_change_password, and — when
123+
// "Generate temporary password" is picked — returns the password ONCE
124+
// in the response for the result dialog. It is never persisted or logged.
125+
name: 'create_user',
126+
label: 'Create User',
127+
icon: 'user-plus',
128+
variant: 'secondary',
129+
locations: ['list_toolbar'],
130+
type: 'api',
131+
target: '/api/v1/auth/admin/create-user',
132+
visible: 'features.admin == true',
133+
successMessage: 'User created',
134+
refreshAfter: true,
135+
params: [
136+
// The endpoint requires email OR phone (phone-only users get a
137+
// placeholder address; requires auth.plugins.phoneNumber).
138+
{ field: 'email', required: false },
139+
{
140+
name: 'phoneNumber',
141+
label: 'Phone Number',
142+
type: 'text',
143+
required: false,
144+
helpText: 'Sign-in phone number (E.164, e.g. +8613800000000). Required when no email is given.',
145+
},
146+
{ field: 'name', required: false },
147+
{
148+
name: 'generatePassword',
149+
label: 'Generate Temporary Password',
150+
type: 'boolean',
151+
required: false,
152+
defaultValue: true,
153+
},
154+
{ name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false },
155+
{
156+
name: 'mustChangePassword',
157+
label: 'Require Password Change On First Login',
158+
type: 'boolean',
159+
required: false,
160+
defaultValue: true,
161+
},
162+
],
163+
resultDialog: {
164+
title: 'User Created',
165+
description:
166+
'Copy the temporary password now — it is shown only once and never stored.',
167+
acknowledge: 'I have saved this password',
168+
fields: [
169+
{ path: 'user.email', label: 'Email', format: 'text' },
170+
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
171+
],
172+
},
173+
},
117174
{
118175
name: 'set_user_password',
119176
label: 'Set Password',
120177
icon: 'key-round',
121178
variant: 'secondary',
122179
locations: ['list_item'],
123180
type: 'api',
181+
// #2766 V1 — same path as better-auth's stock route, but served by the
182+
// plugin-auth wrapper registered ahead of the catch-all: it accepts the
183+
// ADR-0068 platform-admin signals (the stock handler only honors the
184+
// legacy role scalar), can mint a temporary password, and stamps
185+
// must_change_password.
124186
target: '/api/v1/auth/admin/set-user-password',
125187
recordIdParam: 'userId',
126188
successMessage: 'Password updated',
127189
refreshAfter: false,
128190
params: [
129-
{ name: 'newPassword', label: 'New Password', type: 'text', required: true },
191+
{
192+
name: 'generatePassword',
193+
label: 'Generate Temporary Password',
194+
type: 'boolean',
195+
required: false,
196+
defaultValue: false,
197+
},
198+
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false },
199+
{
200+
name: 'mustChangePassword',
201+
label: 'Require Password Change On Next Login',
202+
type: 'boolean',
203+
required: false,
204+
defaultValue: true,
205+
},
130206
],
207+
resultDialog: {
208+
title: 'Password Updated',
209+
description:
210+
'If a temporary password was generated, copy it now — it is shown only once and never stored.',
211+
acknowledge: 'Done',
212+
fields: [
213+
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
214+
],
215+
},
131216
},
132217
{
133218
name: 'set_user_role',
@@ -460,6 +545,46 @@ export const SysUser = ObjectSchema.create({
460545
description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.',
461546
}),
462547

548+
// #2766 V1.5 — phone-number sign-in identifier (better-auth phoneNumber
549+
// plugin, mapped via buildPhoneNumberPluginSchema). Unique when present;
550+
// null for email-only accounts. Written through better-auth, not CRUD.
551+
phone_number: Field.text({
552+
label: 'Phone Number',
553+
required: false,
554+
readonly: true,
555+
searchable: true,
556+
maxLength: 32,
557+
group: 'Account',
558+
description:
559+
'Sign-in phone number (E.164 recommended). Unique per user; managed by ' +
560+
'better-auth when the phoneNumber plugin is enabled.',
561+
}),
562+
563+
phone_number_verified: Field.boolean({
564+
label: 'Phone Verified',
565+
defaultValue: false,
566+
readonly: true,
567+
group: 'Account',
568+
description:
569+
'Whether the phone number has been verified (OTP verification requires ' +
570+
'SMS infrastructure; false until that ships). System-managed.',
571+
}),
572+
573+
// #2766 V1 — admin-issued "must change password on next sign-in" flag.
574+
// Set by the /admin/create-user and /admin/set-user-password routes when
575+
// a temporary password is issued; enforced through the ADR-0069 authGate
576+
// (surfaces as PASSWORD_EXPIRED); cleared by stampPasswordChangedAt once
577+
// the user completes a password change.
578+
must_change_password: Field.boolean({
579+
label: 'Must Change Password',
580+
defaultValue: false,
581+
readonly: true,
582+
group: 'Admin',
583+
description:
584+
'When true, the user is blocked (403 PASSWORD_EXPIRED) until they change ' +
585+
'their password. Stamped by the admin user-management routes; system-managed.',
586+
}),
587+
463588
// ADR-0069 D3 — when enforced MFA first applied to this user; starts the
464589
// grace clock. Stamped lazily at session validation; system-managed.
465590
mfa_required_at: Field.datetime({
@@ -574,6 +699,9 @@ export const SysUser = ObjectSchema.create({
574699
indexes: [
575700
{ fields: ['email'], unique: true },
576701
{ fields: ['created_at'], unique: false },
702+
// #2766 V1.5 — phone sign-in identifier; unique when present (null for
703+
// email-only accounts), also the upsert match key for identity import.
704+
{ fields: ['phone_number'], unique: true },
577705
],
578706

579707
enable: {

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@noble/hashes": "^2.2.0",
2626
"@objectstack/core": "workspace:*",
2727
"@objectstack/platform-objects": "workspace:*",
28+
"@objectstack/rest": "workspace:*",
2829
"@objectstack/spec": "workspace:*",
2930
"@objectstack/types": "workspace:*",
3031
"better-auth": "^1.6.23",

0 commit comments

Comments
 (0)