Skip to content

feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入(#2766 V1/V1.5/V2)#2771

Merged
os-zhuang merged 5 commits into
mainfrom
claude/sys-user-bulk-import-passwords-iv68s9
Jul 10, 2026
Merged

feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入(#2766 V1/V1.5/V2)#2771
os-zhuang merged 5 commits into
mainfrom
claude/sys-user-bulk-import-passwords-iv68s9

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Implements #2766 (re-scoped from #2758) in three staged commits on one branch.

V1 — Admin direct user management(不依赖邮件的建号路径)

  • POST /api/v1/auth/admin/create-user — ADR-0068 platform-admin gate, then a trusted header-less auth.api.createUser call so the account goes through the better-auth pipeline (scrypt hash + credential sys_account). Supports an explicit password (complexity-checked) or a generated temporary password returned once in the response.
  • POST /api/v1/auth/admin/set-user-password — intentionally shadows the stock better-auth route (registered before the catch-all): the stock handler only honors the legacy role === 'admin' scalar, which ADR-0068 platform admins may not carry. Mirrors the stock hash + credential-account core through $context, adds generate-password + must-change stamping.
  • sys_user.must_change_password + a computeAuthGate() branch reusing the PASSWORD_EXPIRED code, so the existing 403 transport seams and the Console change-password redirect apply unchanged (ADR-0069). Cleared by stampPasswordChangedAt on any completed password change. Gate activation uses a lazy-TTL "any flagged user" cache mirroring _orgMfaCache — zero hot-path cost for deployments that never use the feature; primed synchronously on the issuing node.
  • New create_user action + upgraded set_user_password action on sys_user (pure schema; resultDialog one-shot reveal for the temporary password; zero frontend code).
  • features.admin now mirrors enabled.admin (SCIM forces the admin plugin on per ADR-0071; previously advertised false in SCIM deployments).
  • Explicit best-effort sys_audit_log rows — better-auth writes bypass the ObjectQL lifecycle hooks plugin-audit subscribes to.

V1.5 — phoneNumber plugin + placeholder emails(无邮箱员工可建号可登录)

  • better-auth phoneNumber plugin behind opt-in auth.plugins.phoneNumber; phone+password sign-in only (POST /sign-in/phone-number). sendOTP throws NOT_SUPPORTED — the OTP surface needs SMS infrastructure tracked separately.
  • sys_user.phone_number (unique) / phone_number_verified, snake_case-mapped via buildPhoneNumberPluginSchema().
  • Phone-only accounts get a placeholder email u-<random base32>@placeholder.invalid — RFC 2606 reserved TLD (physically undeliverable), local part never derived from the phone number (sys_user has exportCsv enabled; the email column spreads). Every mail callback (reset / invitation / magic link) refuses placeholder recipients with an explicit PLACEHOLDER_EMAIL error.
  • Hardening ride-along: invitation/magic-link "no email service" warn logs no longer print bearer URLs outside dev.

V2 — POST /api/v1/auth/admin/import-users(身份专用批量导入)

  • Reuses the generic import frameworkprepareImportRequest (rows[]/csv/xlsxBase64 + mapping; extracted from rest-server.ts in its own refactor commit, behavior unchanged) and the runImport row engine (dryRun / upsert matching / row results) — with an identity ImportProtocolLike whose createData drives auth.api.createUser per row. The generic /data/sys_user/import path stays closed (ObjectQL direct writes can't produce credentials).
  • passwordPolicy: 'invite' — rows need a real email (row-level INVITE_REQUIRES_EMAIL at validation/dryRun; request-level 400 without an EmailService); created accounts get a set-your-password reset email; email failure marks the row INVITE_EMAIL_FAILED (created, not rolled back).
  • passwordPolicy: 'temporary' — per-row generated passwords returned once in the response; must_change_password stamped. Red-line tests assert no password ever reaches audit rows, logs, or any persisted surface.
  • mode: insert|upsert, matchBy: email|phone; 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. Imported password columns are ignored (pre-hashed migration is explicitly out of scope, per feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入 —— 分阶段实施(#2758 重排) #2766).
  • Sync only, ≤500 rows/request (scrypt ~100ms/row). async: true returns ASYNC_NOT_SUPPORTED with batching guidance — an async job result is a persistence surface and cannot carry temporary passwords; the async invite-flow job is a tracked follow-up. No undo by design (undo = bulk-deleting users).

Testing

  • 353 tests across the touched packages (plugin-auth 339, incl. 14 new import tests + red-line no-password-leak assertions; rest 227 with the existing import-integration suite as the extraction regression gate; spec 6676; platform-objects 71) — all green via turbo run test.
  • tsc --noEmit clean for plugin-auth; rest has two pre-existing unrelated errors in package-routes.ts (untouched).

Runtime-verify notes for reviewers

  • auth.api.createUser header-less trusted-call semantics and requestPasswordReset were verified against the installed better-auth@1.6.23 dist.
  • data: { phoneNumber } on createUser persisting the plugin schema field is the documented carrier for plugin user-model fields; worth one end-to-end smoke on a real DB alongside the must_change_password 403 → change-password → clear loop.

Ref #2766, #2758. Related: #2756 (username sign-in, orthogonal), #2504/#2460 (generic import framework, reused here).

🤖 Generated with Claude Code

https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o


Generated by Claude Code

claude added 3 commits July 10, 2026 10:03
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
…port-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
…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
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 10, 2026 10:36am

Request Review

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file protocol:system tests labels Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/rest, @objectstack/spec.

96 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/cli.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx (via packages/rest, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth, @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via packages/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth, @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Jul 10, 2026
Comment thread packages/plugins/plugin-auth/src/admin-user-endpoints.ts Fixed
Comment thread packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts Fixed
…ed 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
@os-zhuang os-zhuang marked this pull request as ready for review July 10, 2026 11:42
@os-zhuang os-zhuang merged commit 2a71f48 into main Jul 10, 2026
18 checks passed
@os-zhuang os-zhuang deleted the claude/sys-user-bulk-import-passwords-iv68s9 branch July 10, 2026 11:42
os-zhuang added a commit that referenced this pull request Jul 11, 2026
* docs(auth): document phone sign-in, admin user management, and bulk user import

Catches content/docs/permissions/authentication.mdx up with the shipped
auth surface (#2766 / PR #2771, SMS follow-up #2780 / PR #2797):

- Phone-Number Sign-In section: plugins.phoneNumber flag (and the CLI
  OS_AUTH_PHONE_NUMBER_ENABLED override), /sign-in/phone-number, the
  OTP flows' dependency on a wired SMS service (rate-limited), and the
  placeholder-email semantics for phone-only accounts.
- Admin User Management section: /admin/create-user (one-time temporary
  password + mustChangePassword → 403 PASSWORD_EXPIRED gate),
  /admin/set-user-password, /admin/import-users (invite/temporary
  policies incl. the SMS-invite path, upsert profile-field-only rules,
  500-row sync cap, no undo).
- API endpoint reference: new Phone Number and Admin User Management
  subsections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o

* docs(auth): baseline better-auth 'role' boundary refs + regen reference docs

check-role-word (ADR-0090 D3) flagged the two new mentions in
authentication.mdx — both refer to better-auth's literal `role` scalar
(a genuine boundary), so the file is baselined rather than reworded.
Also picks up the generated reference docs that lag the merged spec:
features.phoneNumber/phoneNumberOtp and the auth-config phoneNumber flag
(#2766/#2780), plus component.mdx's relationshipValueField row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants