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

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 framework — prepareImportRequest (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>

This branch was successfully deployed

1 active deployment
Preview — 25c9b070 Deployed Jul 10, 2026 by vercel[bot]
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 size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants