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
21 changes: 21 additions & 0 deletions .changeset/create-user-organization-join.md
Original file line number Diff line number Diff line change
@@ -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

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

**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.
4 changes: 4 additions & 0 deletions content/docs/deployment/tenancy-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
</Callout>

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 |
Expand Down
26 changes: 21 additions & 5 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -951,10 +951,10 @@ re-adjudicated.
<Callout type="info" title="On a walled deployment this is the only way to attach an existing user">
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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -577,6 +577,7 @@ const result = AutomationApiErrorCode.parse(data);
* `NOT_FOUND`
* `NOT_OVERRIDABLE`
* `NOT_UNDOABLE`
* `NO_ACTIVE_ORGANIZATION`
* `NO_DRAFT`
* `NO_EXECUTOR`
* `NO_IDENTITY`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -258,6 +258,7 @@ const result = ApiErrorSchema.parse(data);
* `NOT_FOUND`
* `NOT_OVERRIDABLE`
* `NOT_UNDOABLE`
* `NO_ACTIVE_ORGANIZATION`
* `NO_DRAFT`
* `NO_EXECUTOR`
* `NO_IDENTITY`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ const result = ErrorCode.parse(data);
* `NOT_FOUND`
* `NOT_OVERRIDABLE`
* `NOT_UNDOABLE`
* `NO_ACTIVE_ORGANIZATION`
* `NO_DRAFT`
* `NO_EXECUTOR`
* `NO_IDENTITY`
Expand Down
24 changes: 24 additions & 0 deletions docs/adr/0093-tenancy-mode-and-membership-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**:
Expand Down
Loading
Loading