Skip to content

Commit 17f8217

Browse files
baozhoutaoclaude
andauthored
feat(plugin-auth): multi-org Create User joins the administrator's current organization (#25626)
Refs #25625 (close it by hand once this merges: the branch name is fixed by the session, so it cannot carry the `claude/issue-N-…` claim the closing-target guard reads) ## What changes for the admin In multi-org mode, **Users → Create User** adds the new user to the organization the administrator is working in, so the follow-up **Add Member** is no longer needed. Mainstream admin consoles behave the same way. - There is no organization choice. To add a user to another organization, switch to it first. - An administrator with no current organization is refused with "switch to an organization first". The route never creates a member-less account. - Single-org creation, self sign-up, bulk import, SSO sign-in and invitations are unchanged. Reworked per the maintainer's ruling 「25626 按当前组织」 (#25625 comment 6386597064, confirmed by the maintainer in session): 「当前组织的管理员增加用户就应该加入这个组织」. The earlier Organization picker, the `organizationId` request parameter, its `multiOrgEnabled` gated-input row and the four translations are removed. `@objectstack/spec` and `@objectstack/platform-objects` are byte-identical to `main`. ## How - Under a walled posture (`tenancy.isolationActive`, the predicate behind `features.multiOrgEnabled`), `POST /api/v1/auth/admin/create-user` reads the caller's `activeOrganizationId` from their session before any account exists: - **None:** `400 NO_ACTIVE_ORGANIZATION`, nothing created. - **Otherwise:** the new user joins it as `member` through better-auth's own `addMember` (the Add Member act), inside the account's transaction. A refused join returns the vendor's status and code, and no user is created. An existing membership is kept (`membershipCreated: false`). - `membershipPolicy: 'invite-only'` still joins. Creating a user inside your organization is an explicit act (ADR-0093 D1). - A walled server without the organization plugin or a unit of work answers `501` before any account exists. - **ADR-0093:** the D3 amendment records this one rule. The reconciler and every other creation path still bind nothing in multi-org mode. - The docs (`authentication.mdx`, `tenancy-modes.mdx`) are updated. The changeset is BREAKING (narrowing): FROM a member-less account TO `400 NO_ACTIVE_ORGANIZATION` for an administrator with no current organization. The fix is to switch to an organization first. ## Verification - Unit (`admin-user-endpoints.test.ts`) covers: - joining the current organization, where a body `organizationId` chooses nothing - invite-only - no current organization (400, no account) - an existing membership - vendor refusal with rollback - the 501s - single-org and degraded postures, unchanged - the dialog asking for no organization The plugin-auth and platform-objects suites pass. - Dogfood: `create-user-organization-join.dogfood.test.ts` runs on a real `isolated` stack: - J1: the user joins the admin's current organization, even when the body names another. - J2: with no current organization the request is refused and no `sys_user` row exists. The existing create-user, permission-set, reconciler, audit, self-registration and sign-up dogfoods pass (44 tests). - `pnpm lint`, typecheck, `check:i18n`, `check:adr-0087-registration`, `check:engine-double-contract`, `check:role-word`, `check:doc-authoring` and `check:adr-anchors` pass. ## Landing This diff touches `docs/adr/**` (Tier H). It lands only after an authorized approving review. No AI seat queues it before that. Clause-②: yes (narrowing) Metadata-protocol: none --- _Generated by [Claude Code](https://claude.ai/code/session_01VEz8GXcBLFWiasohtBhYgR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 557973d commit 17f8217

13 files changed

Lines changed: 560 additions & 21 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(plugin-auth): in multi-org mode, Create User adds the new user to the administrator's current organization
7+
8+
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)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one admin route's answer for an administrator who has no current organization under a walled posture; the caller's migration is the one the refusal names: switch to an organization, then create the user. -->
12+
13+
**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.
14+
15+
- **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.
16+
- **No choice to make.** The dialog and the route take no organization. To add a user to another organization, switch to it first.
17+
- **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.
18+
- **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).
19+
- **Capability.** A walled server without the organization plugin, or without a unit of work for the creation, answers `501` before any account exists.
20+
- **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.
21+
- **Unchanged.** The `single` posture (a degraded wall included), self sign-up, bulk import, SSO sign-in and invitations.

‎content/docs/deployment/tenancy-modes.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ round-trip that phone-number-only accounts cannot complete. Its admit set is
250250
organization](/docs/permissions/authentication#attaching-an-existing-user-to-an-organization).
251251
</Callout>
252252

253+
Under a walled posture the admin **Create User** flow is the one exception: it
254+
joins the new user to the administrator's own current organization — the one
255+
they are working in, never a guess (ADR-0093 D3 amendment). See [Create a User Directly](/docs/permissions/authentication#create-a-user-directly).
256+
253257
### Membership policy
254258

255259
| Policy | Behavior |

‎content/docs/permissions/authentication.mdx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -951,10 +951,10 @@ re-adjudicated.
951951
<Callout type="info" title="On a walled deployment this is the only way to attach an existing user">
952952
Under a walled tenancy posture the other paths do not reach:
953953

954-
- **Create User** binds a new account to an organization only when there is an
955-
unambiguous one to bind to. Under the organization wall there is not, by
956-
design, so no membership row is written — see [Membership: how new users join
957-
an organization](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization).
954+
- **Create User** joins only the account it is creating — to the
955+
administrator's current organization (see [Create a User
956+
Directly](#create-a-user-directly)). It never attaches an account that
957+
already exists.
958958
- **Hand-writing the membership row** is not available: `sys_member` is managed
959959
by better-auth and generic CRUD on it is suppressed under the ADR-0010 lock.
960960
- **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
990990
mustChangePassword: true // default true
991991
})
992992
});
993-
// → { success, data: { user, temporaryPassword? } }
993+
// → { success, data: { user, organizationId?, membershipCreated, temporaryPassword? } }
994994
```
995995

996+
**Which organization the user joins.** Under a walled posture (`group` /
997+
`isolated`) the new user joins the calling administrator's **current**
998+
(active) organization as `member`, in the same step as the account — the
999+
organization the administrator has switched to in the console. There is no
1000+
organization choice: to add someone to another organization, switch to it
1001+
first. An administrator with no current organization is refused with
1002+
`400 NO_ACTIVE_ORGANIZATION` and no user is created. Under the `single` posture
1003+
(a degraded wall included) the user joins the default organization, as every new
1004+
user does ([Membership](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization)).
1005+
1006+
The join is better-auth's `addMember`, the same act as **Add Member**, inside
1007+
the account's transaction: an organization at its membership limit refuses the
1008+
call with that verdict, and **no user is created**. It applies under
1009+
`membershipPolicy: 'invite-only'` too. Raising the new member above `member`
1010+
stays a separate, audited step on the organization's Members list.
1011+
9961012
A generated temporary password is returned **once** in the response (the
9971013
Users-list action reveals it in a one-shot dialog) and is never persisted or
9981014
logged. While `mustChangePassword` is set, the user is blocked with

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data);
354354

355355
| Property | Type | Required | Description |
356356
| :--- | :--- | :--- | :--- |
357-
| **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 |
357+
| **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 |
358358
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
359359

360360
### Allowed Values: `FlowInnerRefusal.code`
@@ -577,6 +577,7 @@ const result = AutomationApiErrorCode.parse(data);
577577
* `NOT_FOUND`
578578
* `NOT_OVERRIDABLE`
579579
* `NOT_UNDOABLE`
580+
* `NO_ACTIVE_ORGANIZATION`
580581
* `NO_DRAFT`
581582
* `NO_EXECUTOR`
582583
* `NO_IDENTITY`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **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) |
31+
| **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) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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);
258258
* `NOT_FOUND`
259259
* `NOT_OVERRIDABLE`
260260
* `NOT_UNDOABLE`
261+
* `NO_ACTIVE_ORGANIZATION`
261262
* `NO_DRAFT`
262263
* `NO_EXECUTOR`
263264
* `NO_IDENTITY`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ const result = ErrorCode.parse(data);
425425
* `NOT_FOUND`
426426
* `NOT_OVERRIDABLE`
427427
* `NOT_UNDOABLE`
428+
* `NO_ACTIVE_ORGANIZATION`
428429
* `NO_DRAFT`
429430
* `NO_EXECUTOR`
430431
* `NO_IDENTITY`

‎docs/adr/0093-tenancy-mode-and-membership-lifecycle.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,30 @@ already relies on ("whichever runs first wins and the other no-ops"). "Framework
235235
never guesses in multi mode" is therefore not a hedge — it is exactly the
236236
division of labor the production host already assumes.
237237

238+
#### D3 amendment (2026-10-24) — Setup's Create User joins the administrator's current organization
239+
240+
Decided by the maintainer (「25626 按当前组织」): in multi mode an administrator
241+
always works inside one current organization — the browser switches between
242+
them — so a user they create from Setup belongs to that organization, the way
243+
mainstream admin consoles behave. `POST /admin/create-user` under a walled
244+
posture (`tenancy.isolationActive`) therefore joins the new user to the
245+
**calling administrator's current (active) organization**, as `member`, through
246+
better-auth's `addMember` (the Add Member act) inside the account's own
247+
transaction; a refused join creates no user. There is no organization choice:
248+
to add a user to another organization, switch to it first.
249+
250+
- `membershipPolicy: 'invite-only'` still joins — creating a user inside your
251+
organization is an explicit act, like Add Member (D1).
252+
- An administrator with no current organization is refused
253+
(`400 NO_ACTIVE_ORGANIZATION`, "switch to an organization first"), never
254+
handed a member-less account.
255+
- Single mode (a degraded wall included) is unchanged: the reconciler binds the
256+
default org.
257+
258+
This is not the reconciler guessing a tenant: the reconciler, and every other
259+
creation path (self sign-up, bulk import, SSO JIT, host hooks), still binds
260+
nothing in multi mode.
261+
238262
### D4 — The `tenancy` kernel service
239263

240264
Registered under the service name **`tenancy`**:

0 commit comments

Comments
 (0)