Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/import-users-auto-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/plugin-auth": minor
---

**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`).

`auto` decides **per row** instead of forcing one policy on the whole batch:

- 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;
- 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.

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).

The three existing policies are unchanged and still selectable explicitly:

- `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.
- `temporary` — force a generated temporary password for every row.
- `none` — identity only, no password and no invitation.

**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).
40 changes: 26 additions & 14 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -698,17 +698,28 @@ SSO-onboarded users who never had a local password.
generic data import (`rows[]` / `csv` / `xlsxBase64`, `dryRun`), but writes
every row through better-auth so the accounts are login-capable:

- `passwordPolicy: 'none'` (**default**) — identity only: accounts are created
without a credential record. Users first sign in through a channel (phone
OTP, magic link, or a password-reset link) and the Console detects the
missing password (`hasLocalPassword()`) and offers set-initial-password.
- `passwordPolicy: 'invite'` — `none` plus an invitation per created account:
rows need a reachable identity — a real email (set-your-password email;
requires an email service) or, for phone-only rows, a wired SMS service
(invitation SMS + phone-OTP first sign-in).
- `passwordPolicy: 'temporary'` — the no-infrastructure fallback (no email,
no SMS): per-row generated temporary passwords, returned once in the
response, with `mustChangePassword` enforced.
- `passwordPolicy: 'auto'` (**default**) — decides **per row**: a row with a
deliverable channel (a real email + a wired email service, or a phone + a
wired SMS service) 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. This keeps the temporary-password blast radius to only the rows
that genuinely can't be reached, and — unlike `invite` — it never fails the
request for missing infrastructure (undeliverable rows just degrade). The
per-row outcome is reported as `rows[].delivery` (`email` / `sms` /
`temporary`), with a batch breakdown on `summary.delivery`.
- `passwordPolicy: 'invite'` — force the invite path for **every** row: a real
email gets a set-your-password email (requires an email service), a phone-only
row an invitation SMS (requires a wired SMS service + phone-OTP first sign-in).
Rows that aren't reachable are **failed per-row** (never downgraded) — pick
this when a temporary-password fallback is unacceptable.
- `passwordPolicy: 'temporary'` — force the no-infrastructure path (no email,
no SMS) for every row: per-row generated temporary passwords, returned once in
the response, with `mustChangePassword` enforced.
- `passwordPolicy: 'none'` — identity only: accounts are created without a
credential record. Users first sign in through a channel (phone OTP, magic
link, or a password-reset link) and the Console detects the missing password
(`hasLocalPassword()`) and offers set-initial-password.
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
updates only touch profile fields (`name`, `phone_number`, `role`) — a
re-imported file can never modify an existing user's email or reset their
Expand All @@ -718,8 +729,9 @@ every row through better-auth so the accounts are login-capable:

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

---
Expand Down Expand Up @@ -847,7 +859,7 @@ All endpoints are available under `/api/v1/auth/*`:

- `POST /api/v1/auth/admin/create-user` - Create a login-capable account (optional one-time temporary password + forced change)
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `none` (default) / `invite` / `temporary` password policy; ≤500 rows, dry-run supported)
- `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)
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early

For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs).
Expand Down
141 changes: 138 additions & 3 deletions packages/plugins/plugin-auth/src/admin-import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,21 @@ describe('runAdminImportUsers — request validation', () => {
expect(m.createUser).not.toHaveBeenCalled();
});

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

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

// #3236 — `auto` prefers the invite path per row and only falls back to a
// temporary password for rows with no deliverable channel. Unlike `invite`, it
// never rejects the request for missing infrastructure.
describe('runAdminImportUsers — auto policy (default)', () => {
it('invites deliverable rows and falls back to temporary only for the unreachable ones — in ONE batch', async () => {
// Email service up, SMS down. Row 1: real email → email invite. Row 2:
// phone-only with no SMS → temporary fallback (the ONLY row that gets a
// shared secret). That per-row split is the whole point of #3236.
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'auto', format: 'json',
rows: [
{ email: 'reachable@x.co', name: 'Mail' },
{ phone_number: '+8613800000001', name: 'PhoneOnly' },
],
}),
ACTOR,
);
expect(res.status).toBe(200);
const data = res.body.data as any;
expect(data.summary.created).toBe(2);
expect(data.summary.passwordPolicy).toBe('auto');

// Row 1 invited by email; no temporary password.
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
expect(m.requestPasswordReset.mock.calls[0][0].body.email).toBe('reachable@x.co');
expect(data.rows[0].delivery).toBe('email');
expect(data.rows[0].temporaryPassword).toBeUndefined();

// Row 2 fell back to temporary: a returned password + must_change stamp.
expect(m.sendInviteSms).not.toHaveBeenCalled();
expect(typeof data.rows[1].temporaryPassword).toBe('string');
expect(data.rows[1].delivery).toBe('temporary');
const stamps = m.update.mock.calls.filter((c) => c[1]?.must_change_password === true);
expect(stamps.length).toBe(1);
expect(m.noteMustChangePasswordIssued).toHaveBeenCalled();

// Breakdown surfaced on the summary — one invite, one fallback.
expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 0, temporary: 1 });
expectNoPasswordLeak(m, [data.rows[1].temporaryPassword]);
});

it('uses the SMS invite path for phone-only rows when SMS is wired', async () => {
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'auto', format: 'json',
rows: [
{ email: 'a@x.co' },
{ phone_number: '+86 138 0000 0002' },
],
}),
ACTOR,
);
const data = res.body.data as any;
expect(data.summary.created).toBe(2);
// Email row → reset email; phone-only row → SMS invite. No temp passwords.
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
expect(m.sendInviteSms).toHaveBeenCalledTimes(1);
expect(m.sendInviteSms.mock.calls[0][0]).toBe('+8613800000002');
expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true);
expect(data.rows[0].delivery).toBe('email');
expect(data.rows[1].delivery).toBe('sms');
expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 1, temporary: 0 });
});

it('never rejects for missing infrastructure — with no channels wired every row degrades to temporary', async () => {
const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: false });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'auto', format: 'json',
rows: [
{ email: 'a@x.co' },
{ phone_number: '+8613800000003' },
],
}),
ACTOR,
);
// Contrast with `invite`, which 400s here. `auto` provisions everyone.
expect(res.status).toBe(200);
const data = res.body.data as any;
expect(data.summary.created).toBe(2);
expect(m.requestPasswordReset).not.toHaveBeenCalled();
expect(m.sendInviteSms).not.toHaveBeenCalled();
expect(data.rows.every((r: any) => typeof r.temporaryPassword === 'string')).toBe(true);
expect(data.rows.every((r: any) => r.delivery === 'temporary')).toBe(true);
expect(data.summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 2 });
expectNoPasswordLeak(m, data.rows.map((r: any) => r.temporaryPassword));
});

it('surfaces INVITE_EMAIL_FAILED (no rollback) when a chosen email invite fails', async () => {
const m = makeDeps({ resetFails: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({ passwordPolicy: 'auto', format: 'json', rows: [{ email: 'a@x.co' }] }),
ACTOR,
);
const row = (res.body.data as any).rows[0];
expect(row.ok).toBe(true);
expect(row.action).toBe('created');
expect(row.code).toBe('INVITE_EMAIL_FAILED');
expect(row.delivery).toBe('email');
// A failed invite is NOT silently downgraded to a temporary password.
expect(row.temporaryPassword).toBeUndefined();
});

it('writes nothing on dryRun but still projects the per-row plan', async () => {
const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'auto', dryRun: true, format: 'json',
rows: [{ email: 'a@x.co' }, { phone_number: '+8613800000006' }],
}),
ACTOR,
);
expect(res.status).toBe(200);
expect(m.createUser).not.toHaveBeenCalled();
expect(m.requestPasswordReset).not.toHaveBeenCalled();
expect(m.sendInviteSms).not.toHaveBeenCalled();
// dryRun does not deliver, so no per-row delivery is stamped.
expect((res.body.data as any).summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 0 });
});
});

describe('runAdminImportUsers — upsert', () => {
it('matches by email: updates profile fields only, never credentials or email', async () => {
const m = makeDeps({
Expand Down
Loading
Loading