You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 17f8217
Browse filesBrowse the repository at this point in the historyBrowse files
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>
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.
Copy file name to clipboardExpand all lines: content/docs/references/api/automation-api.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data);
354
354
355
355
| Property | Type | Required | Description |
356
356
| :--- | :--- | :--- | :--- |
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 |
358
358
|**message**|`string`| ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
359
359
360
360
### Allowed Values: `FlowInnerRefusal.code`
@@ -577,6 +577,7 @@ const result = AutomationApiErrorCode.parse(data);
Copy file name to clipboardExpand all lines: content/docs/references/api/contract.mdx
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
28
28
29
29
| Property | Type | Required | Description |
30
30
| :--- | :--- | :--- | :--- |
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) |
32
32
|**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) |
|**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);
0 commit comments