Skip to content

Commit 616e839

Browse files
os-zhuangclaude
andauthored
feat(auth): bulk import defaults to auto — invite per row, temporary only for undeliverable rows (#3236) (#3307)
The identity bulk-import endpoint (`POST /api/v1/auth/admin/import-users`) gains a fourth `passwordPolicy`, `auto`, and it is now the default (was `none`). `auto` decides PER ROW instead of forcing one policy on the whole batch: - a row with a deliverable channel (real email + wired email service, or phone + wired SMS-invite) is INVITED — no shared secret leaves the server; - a row with no deliverable channel (placeholder email, phone-only without SMS, or an email row when no email service is wired) falls back to a temporary password, returned once with `must_change_password` stamped. This shrinks the temporary-password blast radius from "the whole batch" to "only the rows that genuinely can't be reached", and — unlike `invite` — `auto` never rejects the request for missing infrastructure (undeliverable rows just degrade). The per-row outcome is surfaced on `rows[].delivery` (email/sms/temporary) with a batch breakdown on `summary.delivery` and in the run audit. The write path is refactored to a single per-row plan (resolved in pre-validation, keyed by identity so it survives the coerced-row copy handed to createData) and one unified post-write pass driven by the invite/temporary maps rather than a global policy branch — so the interleaved `auto` batch and the three fixed policies share one code path. `invite`/`temporary`/`none` behave exactly as before. Behavior change: callers that OMIT `passwordPolicy` now get `auto` (proactive invitations / temporary fallback) instead of `none` (identity only). Pass `passwordPolicy: 'none'` explicitly for the old identity-only behavior. Every explicit-policy call is unaffected; the response is a strict superset. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b67d38c commit 616e839

4 files changed

Lines changed: 354 additions & 88 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
**Bulk user import defaults to `auto` — prefer invite per row, temporary only for undeliverable rows (#3236).** The identity import endpoint (`POST /api/v1/auth/admin/import-users`) gains a fourth `passwordPolicy`, **`auto`**, and it is now the **default** (was `none`).
6+
7+
`auto` decides **per row** instead of forcing one policy on the whole batch:
8+
9+
- a row with a deliverable channel — a **real email + a wired email service**, or a **phone + a wired SMS-invite path** — is **invited** (a set-your-password email, or an invitation SMS for phone-only rows), so no shared secret ever leaves the server;
10+
- a row with **no** deliverable channel (placeholder email, phone-only without SMS, or an email row when no email service is wired) falls back to a **temporary password**, returned once in the response with `must_change_password` stamped.
11+
12+
This shrinks the temporary-password blast radius from "the whole batch" to "only the rows that genuinely can't be reached", and — unlike `invite``auto` **never rejects the request for missing infrastructure**: with nothing wired, every row simply degrades to temporary. The per-row outcome is surfaced on `rows[].delivery` (`email` / `sms` / `temporary`) with a batch breakdown on `summary.delivery` (also recorded in the run audit).
13+
14+
The three existing policies are unchanged and still selectable explicitly:
15+
16+
- `invite` — force the invite path for every row; unreachable rows are **failed** per-row (never downgraded). Pick this when a temporary-password fallback is unacceptable.
17+
- `temporary` — force a generated temporary password for every row.
18+
- `none` — identity only, no password and no invitation.
19+
20+
**Behavior change to note:** callers that **omit** `passwordPolicy` previously got `none` (no credential, no outbound message); they now get `auto`, which proactively sends invitations to deliverable rows (and returns temporary passwords for the rest). Callers that want the old identity-only behavior must pass `passwordPolicy: 'none'` explicitly. Every call that already passes an explicit policy is unaffected, and the response is a strict superset (adds the `delivery` fields).

content/docs/permissions/authentication.mdx

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -698,17 +698,28 @@ SSO-onboarded users who never had a local password.
698698
generic data import (`rows[]` / `csv` / `xlsxBase64`, `dryRun`), but writes
699699
every row through better-auth so the accounts are login-capable:
700700

701-
- `passwordPolicy: 'none'` (**default**) — identity only: accounts are created
702-
without a credential record. Users first sign in through a channel (phone
703-
OTP, magic link, or a password-reset link) and the Console detects the
704-
missing password (`hasLocalPassword()`) and offers set-initial-password.
705-
- `passwordPolicy: 'invite'``none` plus an invitation per created account:
706-
rows need a reachable identity — a real email (set-your-password email;
707-
requires an email service) or, for phone-only rows, a wired SMS service
708-
(invitation SMS + phone-OTP first sign-in).
709-
- `passwordPolicy: 'temporary'` — the no-infrastructure fallback (no email,
710-
no SMS): per-row generated temporary passwords, returned once in the
711-
response, with `mustChangePassword` enforced.
701+
- `passwordPolicy: 'auto'` (**default**) — decides **per row**: a row with a
702+
deliverable channel (a real email + a wired email service, or a phone + a
703+
wired SMS service) is **invited** (no shared secret leaves the server); a row
704+
with no deliverable channel (placeholder email, phone-only without SMS, or an
705+
email row when no email service is wired) falls back to a **temporary**
706+
password. This keeps the temporary-password blast radius to only the rows
707+
that genuinely can't be reached, and — unlike `invite` — it never fails the
708+
request for missing infrastructure (undeliverable rows just degrade). The
709+
per-row outcome is reported as `rows[].delivery` (`email` / `sms` /
710+
`temporary`), with a batch breakdown on `summary.delivery`.
711+
- `passwordPolicy: 'invite'` — force the invite path for **every** row: a real
712+
email gets a set-your-password email (requires an email service), a phone-only
713+
row an invitation SMS (requires a wired SMS service + phone-OTP first sign-in).
714+
Rows that aren't reachable are **failed per-row** (never downgraded) — pick
715+
this when a temporary-password fallback is unacceptable.
716+
- `passwordPolicy: 'temporary'` — force the no-infrastructure path (no email,
717+
no SMS) for every row: per-row generated temporary passwords, returned once in
718+
the response, with `mustChangePassword` enforced.
719+
- `passwordPolicy: 'none'` — identity only: accounts are created without a
720+
credential record. Users first sign in through a channel (phone OTP, magic
721+
link, or a password-reset link) and the Console detects the missing password
722+
(`hasLocalPassword()`) and offers set-initial-password.
712723
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
713724
updates only touch profile fields (`name`, `phone_number`, `role`) — a
714725
re-imported file can never modify an existing user's email or reset their
@@ -718,8 +729,9 @@ every row through better-auth so the accounts are login-capable:
718729

719730
In the Console, platform admins reach this from the **Users list → Import**
720731
toolbar button (shown when the admin plugin is enabled). The import wizard
721-
uploads a CSV/Excel file, maps columns, previews with a dry-run, and — for
722-
the `temporary` policy — reveals the generated passwords once with a
732+
uploads a CSV/Excel file, maps columns, previews with a dry-run, and — for any
733+
rows that resolve to a temporary password (all of them under `temporary`, only
734+
the unreachable ones under `auto`) — reveals the generated passwords once with a
723735
download, splitting files above 500 rows into batches automatically.
724736

725737
---
@@ -847,7 +859,7 @@ All endpoints are available under `/api/v1/auth/*`:
847859

848860
- `POST /api/v1/auth/admin/create-user` - Create a login-capable account (optional one-time temporary password + forced change)
849861
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
850-
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `none` (default) / `invite` / `temporary` password policy; ≤500 rows, dry-run supported)
862+
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported)
851863
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
852864

853865
For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs).

packages/plugins/plugin-auth/src/admin-import-users.test.ts

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,21 @@ describe('runAdminImportUsers — request validation', () => {
7575
expect(m.createUser).not.toHaveBeenCalled();
7676
});
7777

78-
it('defaults to the none policy when passwordPolicy is omitted', async () => {
79-
const m = makeDeps();
78+
it('defaults to the auto policy when passwordPolicy is omitted (#3236)', async () => {
79+
const m = makeDeps(); // email service available, no SMS
8080
const res = await runAdminImportUsers(
8181
m.deps,
8282
makeRequest({ format: 'json', rows: [{ email: 'a@b.co' }] }),
8383
ACTOR,
8484
);
8585
expect(res.status).toBe(200);
86-
expect((res.body.data as any).summary.passwordPolicy).toBe('none');
86+
const data = res.body.data as any;
87+
expect(data.summary.passwordPolicy).toBe('auto');
88+
// A deliverable email row is invited, not handed a temporary password.
89+
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
90+
expect(data.rows[0].delivery).toBe('email');
91+
expect(data.rows[0].temporaryPassword).toBeUndefined();
92+
expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 0, temporary: 0 });
8793
});
8894

8995
it('rejects matchBy phone when the phoneNumber plugin is off', async () => {
@@ -401,6 +407,135 @@ describe('runAdminImportUsers — invite policy', () => {
401407
});
402408
});
403409

410+
// #3236 — `auto` prefers the invite path per row and only falls back to a
411+
// temporary password for rows with no deliverable channel. Unlike `invite`, it
412+
// never rejects the request for missing infrastructure.
413+
describe('runAdminImportUsers — auto policy (default)', () => {
414+
it('invites deliverable rows and falls back to temporary only for the unreachable ones — in ONE batch', async () => {
415+
// Email service up, SMS down. Row 1: real email → email invite. Row 2:
416+
// phone-only with no SMS → temporary fallback (the ONLY row that gets a
417+
// shared secret). That per-row split is the whole point of #3236.
418+
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false });
419+
const res = await runAdminImportUsers(
420+
m.deps,
421+
makeRequest({
422+
passwordPolicy: 'auto', format: 'json',
423+
rows: [
424+
{ email: 'reachable@x.co', name: 'Mail' },
425+
{ phone_number: '+8613800000001', name: 'PhoneOnly' },
426+
],
427+
}),
428+
ACTOR,
429+
);
430+
expect(res.status).toBe(200);
431+
const data = res.body.data as any;
432+
expect(data.summary.created).toBe(2);
433+
expect(data.summary.passwordPolicy).toBe('auto');
434+
435+
// Row 1 invited by email; no temporary password.
436+
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
437+
expect(m.requestPasswordReset.mock.calls[0][0].body.email).toBe('reachable@x.co');
438+
expect(data.rows[0].delivery).toBe('email');
439+
expect(data.rows[0].temporaryPassword).toBeUndefined();
440+
441+
// Row 2 fell back to temporary: a returned password + must_change stamp.
442+
expect(m.sendInviteSms).not.toHaveBeenCalled();
443+
expect(typeof data.rows[1].temporaryPassword).toBe('string');
444+
expect(data.rows[1].delivery).toBe('temporary');
445+
const stamps = m.update.mock.calls.filter((c) => c[1]?.must_change_password === true);
446+
expect(stamps.length).toBe(1);
447+
expect(m.noteMustChangePasswordIssued).toHaveBeenCalled();
448+
449+
// Breakdown surfaced on the summary — one invite, one fallback.
450+
expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 0, temporary: 1 });
451+
expectNoPasswordLeak(m, [data.rows[1].temporaryPassword]);
452+
});
453+
454+
it('uses the SMS invite path for phone-only rows when SMS is wired', async () => {
455+
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: true });
456+
const res = await runAdminImportUsers(
457+
m.deps,
458+
makeRequest({
459+
passwordPolicy: 'auto', format: 'json',
460+
rows: [
461+
{ email: 'a@x.co' },
462+
{ phone_number: '+86 138 0000 0002' },
463+
],
464+
}),
465+
ACTOR,
466+
);
467+
const data = res.body.data as any;
468+
expect(data.summary.created).toBe(2);
469+
// Email row → reset email; phone-only row → SMS invite. No temp passwords.
470+
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
471+
expect(m.sendInviteSms).toHaveBeenCalledTimes(1);
472+
expect(m.sendInviteSms.mock.calls[0][0]).toBe('+8613800000002');
473+
expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true);
474+
expect(data.rows[0].delivery).toBe('email');
475+
expect(data.rows[1].delivery).toBe('sms');
476+
expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 1, temporary: 0 });
477+
});
478+
479+
it('never rejects for missing infrastructure — with no channels wired every row degrades to temporary', async () => {
480+
const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: false });
481+
const res = await runAdminImportUsers(
482+
m.deps,
483+
makeRequest({
484+
passwordPolicy: 'auto', format: 'json',
485+
rows: [
486+
{ email: 'a@x.co' },
487+
{ phone_number: '+8613800000003' },
488+
],
489+
}),
490+
ACTOR,
491+
);
492+
// Contrast with `invite`, which 400s here. `auto` provisions everyone.
493+
expect(res.status).toBe(200);
494+
const data = res.body.data as any;
495+
expect(data.summary.created).toBe(2);
496+
expect(m.requestPasswordReset).not.toHaveBeenCalled();
497+
expect(m.sendInviteSms).not.toHaveBeenCalled();
498+
expect(data.rows.every((r: any) => typeof r.temporaryPassword === 'string')).toBe(true);
499+
expect(data.rows.every((r: any) => r.delivery === 'temporary')).toBe(true);
500+
expect(data.summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 2 });
501+
expectNoPasswordLeak(m, data.rows.map((r: any) => r.temporaryPassword));
502+
});
503+
504+
it('surfaces INVITE_EMAIL_FAILED (no rollback) when a chosen email invite fails', async () => {
505+
const m = makeDeps({ resetFails: true });
506+
const res = await runAdminImportUsers(
507+
m.deps,
508+
makeRequest({ passwordPolicy: 'auto', format: 'json', rows: [{ email: 'a@x.co' }] }),
509+
ACTOR,
510+
);
511+
const row = (res.body.data as any).rows[0];
512+
expect(row.ok).toBe(true);
513+
expect(row.action).toBe('created');
514+
expect(row.code).toBe('INVITE_EMAIL_FAILED');
515+
expect(row.delivery).toBe('email');
516+
// A failed invite is NOT silently downgraded to a temporary password.
517+
expect(row.temporaryPassword).toBeUndefined();
518+
});
519+
520+
it('writes nothing on dryRun but still projects the per-row plan', async () => {
521+
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false });
522+
const res = await runAdminImportUsers(
523+
m.deps,
524+
makeRequest({
525+
passwordPolicy: 'auto', dryRun: true, format: 'json',
526+
rows: [{ email: 'a@x.co' }, { phone_number: '+8613800000006' }],
527+
}),
528+
ACTOR,
529+
);
530+
expect(res.status).toBe(200);
531+
expect(m.createUser).not.toHaveBeenCalled();
532+
expect(m.requestPasswordReset).not.toHaveBeenCalled();
533+
expect(m.sendInviteSms).not.toHaveBeenCalled();
534+
// dryRun does not deliver, so no per-row delivery is stamped.
535+
expect((res.body.data as any).summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 0 });
536+
});
537+
});
538+
404539
describe('runAdminImportUsers — upsert', () => {
405540
it('matches by email: updates profile fields only, never credentials or email', async () => {
406541
const m = makeDeps({

0 commit comments

Comments
 (0)