diff --git a/.changeset/create-user-organization-join.md b/.changeset/create-user-organization-join.md new file mode 100644 index 00000000000..4726c0dd0a6 --- /dev/null +++ b/.changeset/create-user-organization-join.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/spec': patch +--- + +feat(plugin-auth): in multi-org mode, Create User adds the new user to the administrator's current organization + +Clause-②: yes (narrowing): under a walled posture `POST /api/v1/auth/admin/create-user` now refuses `400 NO_ACTIVE_ORGANIZATION` when the calling administrator has no current organization, where it used to create a member-less account; a request from an administrator in an organization now also adds the new user to it (widening) +Metadata-protocol: none + + + +**BREAKING** — under a walled tenancy posture (`group` / `isolated`), `/admin/create-user` no longer creates a user with no organization. The administrator's current (active) organization is the new user's organization, the way mainstream admin consoles add people to the workspace you are standing in. ADR-0093 D3 gains an amendment for this one route. + +- **The join.** The new user joins the calling administrator's current organization as `member`, through better-auth's own `addMember`, the same act as Add Member, inside the account's transaction. A refused join (a membership limit) refuses the call with the vendor's status and code, and no user is created. A membership already there is kept and reported as `membershipCreated: false`. The response and the audit row carry `organizationId` and `membershipCreated` as before. +- **No choice to make.** The dialog and the route take no organization. To add a user to another organization, switch to it first. +- **No current organization.** FROM a member-less account TO `400 NO_ACTIVE_ORGANIZATION`, before any account exists. The fix is to switch to the organization the user belongs to, then create them. +- **Policy.** Under `membershipPolicy: 'invite-only'` the user still joins: creating a user inside your organization is an explicit act, like Add Member (ADR-0093 D1). +- **Capability.** A walled server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists. +- **Error code.** `NO_ACTIVE_ORGANIZATION` is registered in `@objectstack/spec`'s error-code ledger under `@objectstack/plugin-auth`. It is better-auth's own code for the same condition on Add Member. +- **Unchanged.** The `single` posture (a degraded wall included), self sign-up, bulk import, SSO sign-in and invitations. diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 4f17d88f413..a47151eb353 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -250,6 +250,10 @@ round-trip that phone-number-only accounts cannot complete. Its admit set is organization](/docs/permissions/authentication#attaching-an-existing-user-to-an-organization). +Under a walled posture the admin **Create User** flow is the one exception: it +joins the new user to the administrator's own current organization — the one +they are working in, never a guess (ADR-0093 D3 amendment). See [Create a User Directly](/docs/permissions/authentication#create-a-user-directly). + ### Membership policy | Policy | Behavior | diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 1eb68ac30b9..ccce0f8d44b 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -951,10 +951,10 @@ re-adjudicated. Under a walled tenancy posture the other paths do not reach: -- **Create User** binds a new account to an organization only when there is an - unambiguous one to bind to. Under the organization wall there is not, by - design, so no membership row is written — see [Membership: how new users join - an organization](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization). +- **Create User** joins only the account it is creating — to the + administrator's current organization (see [Create a User + Directly](#create-a-user-directly)). It never attaches an account that + already exists. - **Hand-writing the membership row** is not available: `sys_member` is managed by better-auth and generic CRUD on it is suppressed under the ADR-0010 lock. - **Invitations** need an email round-trip, which phone-number-only accounts @@ -990,9 +990,25 @@ const response = await fetch('http://localhost:3000/api/v1/auth/admin/create-use mustChangePassword: true // default true }) }); -// → { success, data: { user, temporaryPassword? } } +// → { success, data: { user, organizationId?, membershipCreated, temporaryPassword? } } ``` +**Which organization the user joins.** Under a walled posture (`group` / +`isolated`) the new user joins the calling administrator's **current** +(active) organization as `member`, in the same step as the account — the +organization the administrator has switched to in the console. There is no +organization choice: to add someone to another organization, switch to it +first. An administrator with no current organization is refused with +`400 NO_ACTIVE_ORGANIZATION` and no user is created. Under the `single` posture +(a degraded wall included) the user joins the default organization, as every new +user does ([Membership](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization)). + +The join is better-auth's `addMember`, the same act as **Add Member**, inside +the account's transaction: an organization at its membership limit refuses the +call with that verdict, and **no user is created**. It applies under +`membershipPolicy: 'invite-only'` too. Raising the new member above `member` +stays a separate, audited step on the organization's Members list. + A generated temporary password is returned **once** in the response (the Users-list action reveals it in a one-shot dialog) and is never persisted or logged. While `mustChangePassword` is set, the user is blocked with diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index b39d884bd6f..2c9a40096cc 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -577,6 +577,7 @@ const result = AutomationApiErrorCode.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2b28286be19..c0d1d505b8d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +343 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -258,6 +258,7 @@ const result = ApiErrorSchema.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index c8fc503286e..35335308779 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -425,6 +425,7 @@ const result = ErrorCode.parse(data); * `NOT_FOUND` * `NOT_OVERRIDABLE` * `NOT_UNDOABLE` +* `NO_ACTIVE_ORGANIZATION` * `NO_DRAFT` * `NO_EXECUTOR` * `NO_IDENTITY` diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 0b3ec608ab7..dc690be7941 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -235,6 +235,30 @@ already relies on ("whichever runs first wins and the other no-ops"). "Framework never guesses in multi mode" is therefore not a hedge — it is exactly the division of labor the production host already assumes. +#### D3 amendment (2026-10-24) — Setup's Create User joins the administrator's current organization + +Decided by the maintainer (「25626 按当前组织」): in multi mode an administrator +always works inside one current organization — the browser switches between +them — so a user they create from Setup belongs to that organization, the way +mainstream admin consoles behave. `POST /admin/create-user` under a walled +posture (`tenancy.isolationActive`) therefore joins the new user to the +**calling administrator's current (active) organization**, as `member`, through +better-auth's `addMember` (the Add Member act) inside the account's own +transaction; a refused join creates no user. There is no organization choice: +to add a user to another organization, switch to it first. + +- `membershipPolicy: 'invite-only'` still joins — creating a user inside your + organization is an explicit act, like Add Member (D1). +- An administrator with no current organization is refused + (`400 NO_ACTIVE_ORGANIZATION`, "switch to an organization first"), never + handed a member-less account. +- Single mode (a degraded wall included) is unchanged: the reconciler binds the + default org. + +This is not the reconciler guessing a tenant: the reconciler, and every other +creation path (self sign-up, bulk import, SSO JIT, host hooks), still binds +nothing in multi mode. + ### D4 — The `tenancy` kernel service Registered under the service name **`tenancy`**: diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index b00c8311575..b9052f5dcd7 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1254,3 +1254,196 @@ describe('create-user: an optional permission set, granted under the caller\'s o expect(param).not.toHaveProperty('objectOverride'); }); }); + +// ── multi-org: the administrator's current organization ───────────────────── +// +// In multi-org mode the new user joins the organization the administrator is +// working in, as a `member` (ADR-0093 D3 amendment) — the organization's Add +// Member action folded into the creation. better-auth's own `addMember` runs +// inside the account's unit of work, so a refused join leaves no user behind, +// and an administrator with no current organization is refused before any +// account exists. Single-org creation is unchanged. +describe('create-user in multi-org mode: the new user joins the administrator\'s current organization', () => { + function makeJoinDeps(opts: { + members?: Array<{ organization_id: string; user_id: string }>; + addMember?: (...args: any[]) => Promise; + withSeam?: boolean; + policy?: 'auto' | 'invite-only'; + /** The organization wall in force — `features.multiOrgEnabled`. */ + isolationActive?: boolean; + /** The calling administrator's current organization (`undefined` ⇒ the dep is not wired). */ + callerActiveOrg?: string | null; + } = {}) { + const outcomes: string[] = []; + const runAtomically = vi.fn(async (work: () => Promise): Promise => { + try { + const result = await work(); + outcomes.push('committed'); + return result; + } catch (error) { + outcomes.push('rolled back'); + throw error; + } + }); + const resolveActorContext = vi.fn(async () => undefined); + const addMember = vi.fn(opts.addMember ?? (async ({ body }: any) => ({ id: 'mem-1', ...body }))); + const createUser = vi.fn(async ({ body }: any) => ({ + user: { id: 'user-9', email: body.email, name: body.name }, + })); + const members = opts.members ?? []; + const find = vi.fn(async (object: string, query: any) => { + const where = query?.where ?? {}; + if (object === 'sys_member') { + return members.filter((m) => m.organization_id === where.organization_id && m.user_id === where.user_id); + } + // Multi-org: the reconciler must never be what binds here. + if (object === 'sys_organization') return [{ id: 'org_a' }, { id: 'org_b' }]; + return []; + }); + const engineInsert = vi.fn(async () => ({})); + const callerActiveOrganizationId = vi.fn(async () => opts.callerActiveOrg ?? null); + const m = makeDeps({ + getAuthApi: async () => ({ createUser, addMember }) as any, + getDataEngine: () => ({ + update: vi.fn(async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }), + insert: engineInsert, + find, + }), + getTenancy: () => ({ defaultOrgId: async () => null, isolationActive: opts.isolationActive ?? true }), + ...(opts.policy ? { getMembershipPolicy: () => opts.policy } : {}), + ...(opts.callerActiveOrg !== undefined ? { getCallerActiveOrganizationId: callerActiveOrganizationId } : {}), + ...(opts.withSeam === false ? {} : { createGrant: { resolveActorContext, runAtomically } }), + }); + return { ...m, createUser, addMember, engineInsert, outcomes, runAtomically, resolveActorContext, callerActiveOrganizationId }; + } + + it('joins the current organization as a member through addMember, inside the unit of work, and reports it', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'd@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.outcomes).toEqual(['committed']); + expect(j.addMember).toHaveBeenCalledTimes(1); + expect(j.addMember.mock.calls[0][0]).toEqual({ + body: { userId: 'user-9', role: 'member', organizationId: 'org_current' }, + }); + // The caller's context is not needed for the join: it is the vendor's own act. + expect(j.resolveActorContext).not.toHaveBeenCalled(); + // The reconciler's own insert never ran: one membership, the current organization's. + expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_member')).toHaveLength(0); + expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); + const audit = callsOf(j.engineInsert).find(([object]) => object === 'sys_audit_log')![1]; + expect(JSON.parse(audit.metadata)).toMatchObject({ organizationId: 'org_current', membershipCreated: true }); + }); + + it('an organizationId in the body chooses nothing: the current organization is joined', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); + const res = await runAdminCreateUser( + j.deps, + makeRequest({ email: 'c@b.co', password: 'Explicit1!', organizationId: 'org_b' }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(j.addMember.mock.calls[0][0].body.organizationId).toBe('org_current'); + }); + + it('under invite-only: still joined — creating a user in your organization is an explicit act', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', policy: 'invite-only' }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'i@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.addMember).toHaveBeenCalledTimes(1); + expect((res.body.data as any).membershipCreated).toBe(true); + }); + + it('an administrator with no current organization is refused 400 before any account exists', async () => { + const j = makeJoinDeps({ callerActiveOrg: null }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'z@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('NO_ACTIVE_ORGANIZATION'); + expect(res.body.error?.message).toContain('Switch to the organization'); + expect(j.createUser).not.toHaveBeenCalled(); + expect(j.addMember).not.toHaveBeenCalled(); + }); + + it('a membership already there is respected: no second addMember, membershipCreated false', async () => { + const j = makeJoinDeps({ + callerActiveOrg: 'org_current', + members: [{ organization_id: 'org_current', user_id: 'user-9' }], + }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'e@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.addMember).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: false }); + }); + + it('refused by the vendor: its status and code, the account rolls back, nothing is audited', async () => { + const j = makeJoinDeps({ + callerActiveOrg: 'org_current', + addMember: async () => { + throw { statusCode: 403, body: { code: 'ORGANIZATION_MEMBERSHIP_LIMIT_REACHED', message: 'Membership limit reached' } }; + }, + }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'r@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(403); + expect(res.body.error?.code).toBe('ORGANIZATION_MEMBERSHIP_LIMIT_REACHED'); + expect(res.body.error?.message).toContain('no user was created'); + expect(j.outcomes).toEqual(['rolled back']); + expect(res.body.data).toBeUndefined(); + expect(callsOf(j.engineInsert).filter(([object]) => object === 'sys_audit_log')).toHaveLength(0); + }); + + it('a host with no unit of work refuses 501 before any account exists — never a member-less user', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', withSeam: false }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'w@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(501); + expect(j.createUser).not.toHaveBeenCalled(); + }); + + it('a server without the organization plugin refuses 501 before any account exists', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current' }); + const createUser = vi.fn(); + j.deps.getAuthApi = async () => ({ createUser }) as any; + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'p@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(501); + expect(createUser).not.toHaveBeenCalled(); + }); + + it('single-org (or a degraded wall): the current organization is never read and creation is unchanged', async () => { + const j = makeJoinDeps({ callerActiveOrg: 'org_current', isolationActive: false }); + const res = await runAdminCreateUser(j.deps, makeRequest({ email: 's@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(j.callerActiveOrganizationId).not.toHaveBeenCalled(); + expect(j.addMember).not.toHaveBeenCalled(); + expect(j.runAtomically).not.toHaveBeenCalled(); + }); + + it('single-org: the reconciler still binds the default organization, with the current-organization dep wired', async () => { + const insert = vi.fn(async () => ({})); + const callerActive = vi.fn(async () => 'org_elsewhere'); + const m = makeDeps({ + getDataEngine: () => ({ + update: vi.fn(async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }), + insert, + find: vi.fn(async (object: string) => (object === 'sys_organization' ? [{ id: 'org_default', slug: 'default' }] : [])), + }), + getTenancy: () => ({ defaultOrgId: async () => 'org_default', isolationActive: false }), + getCallerActiveOrganizationId: callerActive, + }); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'so@b.co', password: 'Explicit1!' }), ACTOR); + expect(res.status).toBe(200); + expect(callerActive).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ organizationId: 'org_default', membershipCreated: true }); + const member = callsOf(insert).find(([object]) => object === 'sys_member'); + expect(member?.[1]).toMatchObject({ organization_id: 'org_default', user_id: 'user-9', role: 'member' }); + }); + + it('the Create User dialog asks for no organization', () => { + const action = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); + expect((action?.params ?? []).map((p) => p.name ?? p.field)).not.toContain('organizationId'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index f5905a17468..69e97bdef46 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -101,7 +101,16 @@ export interface AdminUserEndpointDeps { * mis-bind new users into the default org. Wire this everywhere tenancy * is registered. */ - getTenancy?(): { defaultOrgId(): Promise } | undefined; + getTenancy?(): { + defaultOrgId(): Promise; + /** + * True iff an organization wall is in force — the same predicate + * `features.multiOrgEnabled` is derived from. create-user's multi-org join + * of the administrator's current organization reads it. Optional for lean + * mocks: absent ⇒ not multi-org ⇒ no join. + */ + readonly isolationActive?: boolean; + } | undefined; /** * ADR-0093 D1 — the deployment's membership policy, read LIVE through * `AuthManager.getMembershipPolicy()` (never a captured option). @@ -127,8 +136,19 @@ export interface AdminUserEndpointDeps { * [#25338] What create-user's optional `permissionSet` needs. Absent ⇒ a * request that names a set is refused 501 and no user is created; a request * that names none never reads it. See {@link AdminCreateGrantSeam}. + * + * Its unit of work also carries the multi-org join of the administrator's + * current organization, so a refused join rolls the account back as a + * refused grant does. Absent ⇒ a multi-org creation is refused 501. */ createGrant?: AdminCreateGrantSeam; + /** + * The CALLING administrator's current (active) organization, read from their + * session, or `null` when they have none. In multi-org mode the new user + * joins it, and `null` refuses the creation (ADR-0093 D3 amendment). + * Absent ⇒ no join: the creation behaves as before. + */ + getCallerActiveOrganizationId?(): Promise; logger?: { warn(msg: string): void }; } @@ -208,6 +228,7 @@ import { generatePlaceholderEmail } from './placeholder-email.js'; import { revokeSessionsAndApiKeys, type ForcedResetRevocationOutcome } from './forced-reset-revocation.js'; import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js'; import { resolveDefaultOrgId } from './tenancy-service.js'; +import type { AddMemberCapableApi } from './organization-add-member.js'; import { readJsonRequestBody } from './request-json-body.js'; import { cloudManagedRefusal } from './cloud-managed.js'; import { isCloudManaged } from '@objectstack/core'; @@ -670,6 +691,48 @@ export async function runAdminCreateUser( const resolved = await resolvePassword(deps, body, 'password'); if ('status' in resolved) return resolved; + // ADR-0093 D3 amendment — in multi-org mode the new user joins the + // administrator's CURRENT organization, settled BEFORE the account exists: + // the join runs in the account's own unit of work, so an administrator with + // no current organization, or a host that cannot run the join, is refused + // with nothing created — never handed a member-less account. + let join: + | { seam: AdminCreateGrantSeam; addMember: AddMemberCapableApi['addMember']; organizationId: string } + | undefined; + if (multiOrgCreationJoinsCurrentOrganization(deps)) { + const organizationId = await deps.getCallerActiveOrganizationId!(); + if (typeof organizationId !== 'string' || organizationId.length === 0) { + return { + status: 400, + body: { + success: false, + error: { + code: 'NO_ACTIVE_ORGANIZATION', + message: + 'You are not working in an organization, so no user was created. ' + + 'Switch to the organization the new user belongs to, then create them there.', + }, + }, + }; + } + const seam = deps.createGrant; + const authApi = (await deps.getAuthApi()) as Partial; + if (!seam || typeof authApi.addMember !== 'function') { + return { + status: 501, + body: { + success: false, + error: { + code: 'NOT_IMPLEMENTED', + message: + 'This server cannot add a new user to your organization while creating them, so no user was created.', + }, + }, + }; + } + join = { seam, addMember: authApi.addMember.bind(authApi), organizationId }; + } + // [#25338] Everything the grant needs is settled BEFORE the account exists, // so a host that cannot judge the grant refuses with nothing created. let grant: { seam: AdminCreateGrantSeam; engine: AdminUserDataEngine; context: Record } | undefined; @@ -740,11 +803,15 @@ export async function runAdminCreateUser( ? await stampMustChangePassword(deps, userId, true) : false; - // Match the invite / add-member flows: give the new user a membership so a - // single-org deployment shows them under the Default Organization instead of - // as a member-less account. No-op in multi-org (≥2 orgs), and no-op under + // In multi-org mode the administrator's current organization is joined + // explicitly (the add-member act, ADR-0093 D1, D3 amendment). Otherwise match the invite / add-member + // flows: give the new user a membership so a single-org deployment shows + // them under the Default Organization instead of as a member-less account. + // No-op in multi-org (≥2 orgs), and no-op under // `membershipPolicy: 'invite-only'` (ADR-0093 D1) — see the helper. - const membership = await bindUserToSoleOrganization(deps, userId); + const membership = join + ? await joinCurrentOrganization(deps, join.addMember, userId, join.organizationId) + : await bindUserToSoleOrganization(deps, userId); // [#25338] The grant, under the administrator's own context (see // `AdminCreateGrantSeam`). A refusal throws, and the unit of work around @@ -757,7 +824,7 @@ export async function runAdminCreateUser( { context: grant.context }, ); } catch (error) { - throw new GrantRefused(grantRefusal(error)); + throw new CreationRefused(grantRefusal(error)); } } @@ -765,13 +832,14 @@ export async function runAdminCreateUser( }; let account: CreatedAccount | EndpointResult; - if (grant) { + const unitOfWork = grant?.seam ?? join?.seam; + if (unitOfWork) { try { // A creation that fails answers without throwing: nothing landed, so // there is nothing to roll back. - account = await grant.seam.runAtomically(createAccount); + account = await unitOfWork.runAtomically(createAccount); } catch (error) { - if (error instanceof GrantRefused) return error.result; + if (error instanceof CreationRefused) return error.result; throw error; } } else { @@ -827,13 +895,80 @@ interface CreatedAccount { } /** [#25338] Thrown out of the unit of work so it rolls back; carries the refusal the caller answers. */ -class GrantRefused extends Error { +class CreationRefused extends Error { constructor(readonly result: EndpointResult) { - super(result.body.error?.message ?? 'permission set grant refused'); - this.name = 'GrantRefused'; + super(result.body.error?.message ?? 'user creation refused'); + this.name = 'CreationRefused'; } } +/** + * Join the new user to the administrator's current organization, as a + * `member` — the same act as the organization's Add Member action, folded into + * the creation so the administrator does not have to perform it afterwards. + * + * It goes through better-auth's own server-only `addMember` (as the + * `/organization/add-member` mount does), so the vendor's checks — the + * organization exists, the membership limit, its hooks — all stay the + * vendor's. A refusal throws, and the unit of work around the creation rolls + * the account back with it. + * + * ADR-0093 D3 amendment: this is NOT the reconciler guessing a tenant (D3 — + * it still binds nothing in multi mode); it is the organization the + * administrator is working in, which makes it an explicit flow that + * `membershipPolicy: 'invite-only'` admits (D1). The role is `member`, the D1 + * default: elevation stays the separate, audited `update-member-role`. + * + * A membership already there is respected and reported as + * `membershipCreated: false`, exactly as the reconciler reports it. + */ +async function joinCurrentOrganization( + deps: AdminUserEndpointDeps, + addMember: AddMemberCapableApi['addMember'], + userId: string, + organizationId: string, +): Promise<{ organizationId: string; membershipCreated: boolean }> { + const engine = deps.getDataEngine(); + if (engine?.find) { + const existing = await engine.find( + 'sys_member', + { where: { organization_id: organizationId, user_id: userId }, limit: 1 }, + { context: SYSTEM_CTX }, + ); + if (Array.isArray(existing) && existing.length > 0) return { organizationId, membershipCreated: false }; + } + try { + await addMember({ body: { userId, role: 'member', organizationId } }); + } catch (error) { + const refused = mapAuthApiError(error, 'organization/add-member failed'); + throw new CreationRefused({ + status: refused.status, + body: { + success: false, + error: { + code: refused.body.error?.code ?? 'INTERNAL_ERROR', + message: `The user could not be added to the organization, so no user was created: ${ + refused.body.error?.message ?? 'organization/add-member failed' + }`, + }, + }, + }); + } + return { organizationId, membershipCreated: true }; +} + +/** + * Whether this creation joins the administrator's current organization: multi-org + * mode only (ADR-0093 D3 amendment), judged by `tenancy.isolationActive` — the + * predicate behind `features.multiOrgEnabled`, so a single-org deployment and a + * degraded walled request (which serves single-org-like) keep the reconciler's + * default-organization bind, unchanged. A host that wires no way to read the + * caller's current organization does not join either. + */ +function multiOrgCreationJoinsCurrentOrganization(deps: AdminUserEndpointDeps): boolean { + return deps.getTenancy?.()?.isolationActive === true && typeof deps.getCallerActiveOrganizationId === 'function'; +} + /** * [#25338] The answer for a refused grant: the status and code the refusal * itself declared (403 `PERMISSION_DENIED` from the D12 gate, 400 diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8c554309313..d8abb39fce9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3322,7 +3322,23 @@ export class AuthPlugin implements Plugin { // of recorded as the system. Attribution only: the route's own // authorization already happened above, and the writes stay system. const { status, body } = await runAttributedToUser(actor.id, () => - runAdminCreateUser({ ...adminUserDeps(), createGrant: createGrantFor(c) }, c.req.raw, actor), + runAdminCreateUser( + { + ...adminUserDeps(), + createGrant: createGrantFor(c), + // ADR-0093 D3 amendment — multi-org create-user joins the + // administrator's own current organization, read from the + // session this request already authenticated. + getCallerActiveOrganizationId: async () => { + const session = (await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers))) as + | { session?: { activeOrganizationId?: string | null } } + | null; + return session?.session?.activeOrganizationId ?? null; + }, + }, + c.req.raw, + actor, + ), ); return c.json(body, status as any); } catch (error) { diff --git a/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts new file mode 100644 index 00000000000..0f60f1b3a3b --- /dev/null +++ b/packages/qa/dogfood/test/create-user-organization-join.dogfood.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * In multi-org mode, Create User adds the new user to the administrator's + * CURRENT organization — measured on the real route, in a real walled posture. + * + * The membership reconciler binds no organization by itself in multi-org mode + * (ADR-0093 D3 — it never guesses one), so a user created from Setup used to be + * member-less and the administrator had to follow up with Add Member. The D3 + * amendment folds that step in: the organization the administrator is working + * in is the one the new user joins, as `member`, through better-auth's own + * `addMember` inside the account's transaction — the way mainstream admin + * consoles add people to the workspace you are standing in. + * + * J1 in an organization: the user lands with exactly one membership, + * `member` of the administrator's current organization — not another one + * the request names. + * J2 no current organization: refused, and no user is created — never a + * member-less account. + * + * Harness: the admin identity routes 501 unless better-auth's `admin` plugin + * is on; `OS_SCIM_ENABLED` is the one env knob that reaches it (see + * `admin-identity-audit-trail.dogfood.test.ts`). The posture is a real, + * non-degraded `isolated` one (`multiTenant: 'posture-only'`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe("Create User in multi-org mode joins the administrator's current organization", () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + /** An organization the administrator is NOT working in. */ + let otherOrgId: string; + /** The administrator's own current organization. */ + let adminOrgId: string; + let priorScim: string | undefined; + + beforeAll(async () => { + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + adminToken = await stack.signIn(); // the seeded dev admin (platform admin) + ql = await stack.kernel.getServiceAsync('objectql'); + + const ownerToken = await stack.signUp('owner.create-user-org@example.com'); + const other = await stack.apiAs(ownerToken, 'POST', '/auth/organization/create', { + name: 'Elsewhere', slug: 'elsewhere-create-user', + }); + expect(other.status, `org create: ${await other.clone().text()}`).toBe(200); + otherOrgId = ((await other.json()) as { id: string }).id; + + const home = await stack.apiAs(adminToken, 'POST', '/auth/organization/create', { + name: 'Admin Home', slug: 'admin-home-create-user', + }); + expect(home.status, `admin org create: ${await home.clone().text()}`).toBe(200); + adminOrgId = ((await home.json()) as { id: string }).id; + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + async function setActive(organizationId: string | null): Promise { + const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/set-active', { organizationId }); + expect(res.status, `set-active: ${await res.clone().text()}`).toBe(200); + const session = await (await stack.apiAs(adminToken, 'GET', '/auth/get-session')).json(); + expect(session.session.activeOrganizationId ?? null).toBe(organizationId); + } + + it('guard the guard: a real multi-org posture', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ + posture: string; isolationActive: boolean; degraded: boolean; defaultOrgId(): Promise; + }>('tenancy'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + expect(tenancy.degraded).toBe(false); + // The reconciler's target: none — so the membership below is the join's doing, not the reconciler's. + expect(await tenancy.defaultOrgId()).toBeNull(); + expect(adminOrgId).not.toBe(otherOrgId); + }); + + it("J1 in an organization: the user joins the administrator's current one, and only that one", async () => { + await setActive(adminOrgId); + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email: 'joined.create-user-org@example.com', + password: 'Joined!Pass123', + // Not a choice the route offers: the current organization decides. + organizationId: otherOrgId, + }); + expect(res.status, await res.clone().text()).toBe(200); + const body = await res.json(); + expect(body.data.organizationId).toBe(adminOrgId); + expect(body.data.membershipCreated).toBe(true); + const members = await findRows(ql, 'sys_member', { user_id: String(body.data.user.id) }); + expect(members.map((m) => [m.organization_id, m.role])).toEqual([[adminOrgId, 'member']]); + }, 120_000); + + it('J2 no current organization: refused, and no user is created', async () => { + await setActive(null); + const email = 'orphan.create-user-org@example.com'; + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email, + password: 'Orphan!Pass123', + }); + const text = await res.clone().text(); + expect(res.status, text).toBe(400); + expect((await res.json()).error.code).toBe('NO_ACTIVE_ORGANIZATION'); + expect(await findRows(ql, 'sys_user', { email })).toHaveLength(0); + }, 120_000); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 424c142d3a7..f0b02af12d8 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -602,6 +602,11 @@ export const ERROR_CODE_LEDGER = { // can tell an expiry from a reset an administrator asked for. 'PASSWORD_RESET_REQUIRED', 'PASSWORD_REUSE', + // ADR-0093 D3 amendment — 400 on /admin/create-user under a walled posture: + // the calling administrator has no current organization for the new user + // to join, so no account is created. better-auth's own code for the same + // condition on Add Member, so both doors answer it alike. + 'NO_ACTIVE_ORGANIZATION', 'OAUTH_REGISTER_FAILED', // better-auth rejected the client registration 'PHONE_NOT_ENABLED', 'ROLE_NOT_FOUND', // [#24750] 400 on /organization/invite-member and /organization/update-member-role: the requested membership role is outside the closed vocabulary, refused before any invitation or membership row is written; better-auth's code, stamped by plugin-auth on the invite door (`invite-role-not-found-code.ts`), pass-through from better-auth on update-member-role diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 4875e0369d9..ae5560c1e63 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2984,7 +2984,7 @@ { "file": "packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts", "verb": "update", - "pinned": 1 + "pinned": 3 }, { "file": "packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts",