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

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 10, 2026 10:36am

Request Review

@github-actionsgithub-actionsBot 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-actionsgithub-actionsBot added documentation Improvements or additions to documentation tooling labels Jul 10, 2026
Comment threadpackages/plugins/plugin-auth/src/admin-user-endpoints.ts Fixed
Comment threadpackages/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 mainJul 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>
@github-actionsgithub-actionsBot mentioned this pull request Jul 22, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationprotocol:systemsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@os-zhuang@github-advanced-security@claude