diff --git a/.changeset/import-users-none-policy.md b/.changeset/import-users-none-policy.md new file mode 100644 index 0000000000..ceafb3601d --- /dev/null +++ b/.changeset/import-users-none-policy.md @@ -0,0 +1,22 @@ +--- +"@objectstack/plugin-auth": minor +--- + +feat(auth): `passwordPolicy: 'none'` is the identity import's new default — import provisions identity, not credentials + +`POST /api/v1/auth/admin/import-users` now supports (and defaults to) +`passwordPolicy: 'none'`: accounts are created without a credential record +(better-auth's optional-password create), so no password material is +generated, returned, or distributed at all. Users first sign in through a +channel — phone OTP, magic link, or a password-reset link — and the Console's +existing credential-less detection (`hasLocalPassword()` → set-initial-password) +nudges them to set a password afterwards. + +The `invite` policy also no longer mints a throwaway password: it creates the +same credential-less account and sends the set-your-password invitation +(better-auth's reset flow creates the credential record on first set). +`temporary` is unchanged and remains the fallback for deployments without +email/SMS infrastructure. + +Breaking-ish note: `passwordPolicy` was previously required — requests that +omitted it got a 400. They now succeed with the `none` behavior. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 9936b9ebf8..54b33bc3e8 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -683,12 +683,17 @@ 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: 'invite'` — rows need a reachable identity: a real email - (invitation goes out as a 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'` — per-row generated temporary passwords, - returned once in the response, with `mustChangePassword` enforced. +- `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. - `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 diff --git a/packages/plugins/plugin-auth/src/admin-import-users.test.ts b/packages/plugins/plugin-auth/src/admin-import-users.test.ts index 57dcc6954d..b63d108995 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.test.ts @@ -64,13 +64,28 @@ function expectNoPasswordLeak(m: ReturnType, passwords: string[ } describe('runAdminImportUsers — request validation', () => { - it('rejects a missing/invalid passwordPolicy', async () => { + it('rejects an unknown passwordPolicy', async () => { const m = makeDeps(); - const res = await runAdminImportUsers(m.deps, makeRequest({ format: 'json', rows: [] }), ACTOR); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'plaintext', format: 'json', rows: [] }), + ACTOR, + ); expect(res.status).toBe(400); expect(m.createUser).not.toHaveBeenCalled(); }); + it('defaults to the none policy when passwordPolicy is omitted', async () => { + const m = makeDeps(); + 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'); + }); + it('rejects matchBy phone when the phoneNumber plugin is off', async () => { const m = makeDeps({ phoneEnabled: false }); const res = await runAdminImportUsers( @@ -174,6 +189,52 @@ describe('runAdminImportUsers — row validation (also on dryRun)', () => { }); }); +describe('runAdminImportUsers — none policy (default: identity only, no credentials)', () => { + it('creates credential-less accounts: no password sent to better-auth, no stamps, no invitations', async () => { + const m = makeDeps({ phoneEnabled: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'none', format: 'json', + rows: [ + { email: 'a@x.co', name: 'A' }, + { phone_number: '+8613800000001', name: 'B' }, + ], + }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(data.summary.passwordPolicy).toBe('none'); + + // better-auth receives NO password key at all → credential-less account. + for (const call of m.createUser.mock.calls) { + expect('password' in call[0].body).toBe(false); + } + // Phone-only row still gets a placeholder email. + expect(m.createUser.mock.calls[1][0].body.email).toMatch(/@placeholder\.invalid$/); + + // No must_change_password stamps, no temporary passwords, no invitations. + expect(m.update.mock.calls.filter((c) => c[1]?.must_change_password === true).length).toBe(0); + expect(m.noteMustChangePasswordIssued).not.toHaveBeenCalled(); + expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true); + expect(m.requestPasswordReset).not.toHaveBeenCalled(); + expect(m.sendInviteSms).not.toHaveBeenCalled(); + }); + + it('does not require an email or SMS service (unlike invite)', async () => { + const m = makeDeps({ emailAvailable: false, smsInviteAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'none', format: 'json', rows: [{ email: 'a@b.co' }] }), + ACTOR, + ); + expect(res.status).toBe(200); + expect((res.body.data as any).summary.created).toBe(1); + }); +}); + describe('runAdminImportUsers — temporary policy', () => { it('creates each row through better-auth, stamps must_change_password, returns per-row temp passwords once', async () => { const m = makeDeps({ phoneEnabled: true }); diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index 09174ff93d..2277481a42 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -14,15 +14,25 @@ * `ImportProtocolLike` whose `createData` drives `auth.api.createUser`. * * Password policies: - * - `invite` — every row needs a reachable identity. Accounts are created - * with a random throwaway password the user never sees, then - * a "set your password" invitation goes out per created - * account: a reset-password email for rows with a REAL - * email (requires a wired EmailService), or — #2780 — an - * invitation SMS for phone-only rows (requires a wired, + * - `none` — THE DEFAULT. No password is set at all: better-auth + * creates the account without a credential record, and the + * user's first sign-in is channel-based (phone OTP, magic + * link, or an email reset link). The Console already + * detects credential-less accounts (`hasLocalPassword()`) + * and offers `set-initial-password`, so users are nudged to + * set a password after their first OTP sign-in. Import's + * job is identity — credentials are the auth domain's. + * - `invite` — like `none` (credential-less creation; better-auth's + * reset-password creates the credential account on first + * set), plus a "set your password" invitation goes out per + * created account: a reset-password email for rows with a + * REAL email (requires a wired EmailService), or — #2780 — + * an invitation SMS for phone-only rows (requires a wired, * deliverable SmsService + the phoneNumber plugin; the user * first signs in via phone OTP and then sets a password). - * - `temporary` — each created account gets a generated temporary password, + * Rows are validated per-channel reachability. + * - `temporary` — the no-infrastructure fallback (no email, no SMS): each + * created account gets a generated temporary password, * `must_change_password` is stamped (403 PASSWORD_EXPIRED * until changed), and the passwords are returned ONCE in the * HTTP response, one per row. Never persisted, never logged. @@ -95,7 +105,7 @@ interface RowIdentity { function resolveRowIdentity( row: Record, opts: { - policy: 'invite' | 'temporary'; + policy: 'none' | 'invite' | 'temporary'; phoneEnabled: boolean; emailInviteOk: boolean; smsInviteOk: boolean; @@ -162,9 +172,12 @@ export async function runAdminImportUsers( }); // ── Request-level validation ───────────────────────────────────────── - const policy = body?.passwordPolicy; - if (policy !== 'invite' && policy !== 'temporary') { - return fail(400, 'invalid_request', 'passwordPolicy must be "invite" or "temporary"'); + // Default policy: `none` — import provisions identity, not credentials. + // Users first sign in via a channel (phone OTP / magic link / reset link) + // and the Console's hasLocalPassword() flow nudges them to set a password. + const policy = body?.passwordPolicy === undefined ? 'none' : body.passwordPolicy; + if (policy !== 'none' && policy !== 'invite' && policy !== 'temporary') { + return fail(400, 'invalid_request', 'passwordPolicy must be "none" (default), "invite", or "temporary"'); } const mode = body?.mode === 'upsert' ? 'upsert' : body?.mode === 'insert' || body?.mode === undefined ? 'insert' : undefined; if (!mode) return fail(400, 'invalid_request', 'mode must be "insert" or "upsert"'); @@ -254,7 +267,7 @@ export async function runAdminImportUsers( const data: Record = args?.data ?? {}; const email: string = typeof data.email === 'string' && data.email.length > 0 ? data.email - : generatePlaceholderEmail(); // temporary policy only — invite rows were validated to carry one + : generatePlaceholderEmail(); // phone-only rows (none/temporary, or invite via SMS) const placeholder = !(typeof data.email === 'string' && data.email.length > 0); const phone: string | undefined = typeof data.phone_number === 'string' && data.phone_number.length > 0 ? data.phone_number : undefined; const name: string = typeof data.name === 'string' && data.name.trim().length > 0 @@ -262,16 +275,17 @@ export async function runAdminImportUsers( : placeholder ? (phone as string) : email.split('@')[0]; const role: string | undefined = typeof data.role === 'string' && data.role.length > 0 ? data.role : undefined; - // Both policies create with a generated password: `temporary` hands it - // to the caller; `invite` throws it away (the user sets their own via - // the reset link) — the account is never password-less. - const password = generateTemporaryPassword(); + // Only the `temporary` policy sets a password. `none`/`invite` create + // credential-less accounts (better-auth: omitted password → no + // credential record); the credential is minted later by the user via + // set-initial-password / the reset flow, which creates it on demand. + const password = policy === 'temporary' ? generateTemporaryPassword() : undefined; const created = await authApi.createUser({ body: { email, name, - password, + ...(password ? { password } : {}), ...(role ? { role } : {}), ...(phone ? { data: { phoneNumber: phone } } : {}), }, @@ -280,16 +294,17 @@ export async function runAdminImportUsers( if (!id) throw Object.assign(new Error('better-auth returned no user id'), { code: 'CREATE_FAILED' }); if (policy === 'temporary') { - temporaryPasswords.set(id, password); + temporaryPasswords.set(id, password as string); try { await engine.update('sys_user', { id, must_change_password: true }, { context: SYSTEM_CTX } as any); deps.noteMustChangePasswordIssued(); } catch (e) { deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`); } - } else { + } else if (policy === 'invite') { createdEmails.set(id, { email, placeholder, ...(phone ? { phone } : {}) }); } + // `none`: nothing else to do — identity only. return { id }; },