Skip to content

Commit 68ca346

Browse files
os-zhuangclaude
andauthored
docs: document POST /organization/add-member and its platform-admin-only admit set (#10535)
The route landed in #9941 (PR #10049) but was never written up. It is an ObjectStack mount over better-auth's server-only `auth.api.addMember` (the vendor declares it with no HTTP path), so neither the vendor's docs nor an enumeration of better-auth's mounted surface reaches it. Documents the request shape, the ADR-0068 platform-admin-only admit set (org owners and admins are refused 403), the full ADR-0112 refusal set with both `code` and `status`, and the multi-org context that makes this the only path to attach an existing user under the organization wall. Also cross-links it from tenancy-modes.mdx, which names `add-member` three times without ever saying what URL it is. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24d8bc8 commit 68ca346

2 files changed

Lines changed: 92 additions & 0 deletions

File tree

content/docs/deployment/tenancy-modes.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ wall and did not get one, and the safe reading of that is "I don't know which or
167167
this user belongs to", not "everyone belongs to the only org I can see". So
168168
`defaultOrgId()` returns `null` there too.
169169

170+
<Callout type="info" title="`add-member` is a route — here it is">
171+
The `add-member` named above is `POST /api/v1/auth/organization/add-member`, an
172+
ObjectStack mount wrapping better-auth's server-only `addMember`. Under a walled
173+
posture it is the **only** way to attach an *existing* account to an
174+
organization: the reconciler declines to guess a target org, generic `sys_member`
175+
writes are suppressed under the ADR-0010 lock, and invitations need an email
176+
round-trip that phone-number-only accounts cannot complete. Its admit set is
177+
**platform admin only** — an organization owner or administrator is refused
178+
`403 PERMISSION_DENIED`. See [Attaching an existing user to an
179+
organization](/docs/permissions/authentication#attaching-an-existing-user-to-an-organization).
180+
</Callout>
181+
170182
### Membership policy
171183

172184
| Policy | Behavior |

content/docs/permissions/authentication.mdx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,82 @@ more authority than the issuer has.** An issuer below `admin` grade may invite
756756
as plain `member` only, and no invitation may confer a tier above the issuer's
757757
own.
758758

759+
#### Attaching an existing user to an organization
760+
761+
`POST /api/v1/auth/organization/add-member` attaches an **existing** account to
762+
an organization directly — no invitation, no email round-trip.
763+
764+
```http
765+
POST /api/v1/auth/organization/add-member
766+
{
767+
"userId": "usr_01HZX…",
768+
"organizationId": "org_beta"
769+
}
770+
```
771+
772+
The body also carries the membership tier, under better-auth's own column name
773+
for it — the same field the invitation example above sends (see [Membership
774+
tiers are a closed list](#membership-tiers-are-a-closed-list)). It accepts a
775+
single tier name or an array of them. `snake_case` spellings are accepted
776+
alongside camelCase for the identifiers (`user_id`, `organization_id`,
777+
`team_id`), so a value pasted straight out of the record grid works.
778+
779+
`organizationId` may be omitted, in which case the membership lands in the
780+
**calling admin's active organization**. `teamId` is optional and has no such
781+
fallback: omit it and the member simply joins no team.
782+
783+
<Callout type="warn" title="This is an ObjectStack mount, not a better-auth route">
784+
better-auth declares `addMember` as a **server-only** API with no HTTP path of
785+
its own — measured on 1.7.1, where `addMember` builds its endpoint with no path
786+
argument while every sibling in the same module (`/organization/remove-member`,
787+
`/organization/list-members`, `/organization/leave`, …) passes one. So the
788+
vendor's documentation does not list this URL, and an audit that enumerates
789+
better-auth's mounted surface will not find it there either. ObjectStack mounts it ahead of the
790+
catch-all and wraps the vendor's server-only endpoint; the vendor's own checks
791+
(already-a-member, membership limit, team resolution, hooks) are not
792+
reimplemented.
793+
</Callout>
794+
795+
**The admit set is platform admin only.** An organization owner or organization
796+
administrator is refused `403 PERMISSION_DENIED`, and that is deliberate under
797+
ADR-0068: standing inside an organization is not standing on the platform.
798+
Attaching someone to an organization without their consent is a
799+
platform-operator action, and the vendor endpoint performs no authorization of
800+
its own precisely because it was built to be called only from trusted server
801+
code.
802+
803+
Refusals, with both halves of the ADR-0112 envelope:
804+
805+
| Status | `code` | When |
806+
|---|---|---|
807+
| `401` | `UNAUTHENTICATED` | No session. Checked before the body, so an anonymous caller with a malformed body still gets `401`. |
808+
| `403` | `PERMISSION_DENIED` | Signed in, but not a platform admin — including organization owners and administrators. |
809+
| `501` | `NOT_IMPLEMENTED` | The organization plugin is off (`auth.plugins.organization`). Checked before the body: a payload nit is not worth reporting for a capability the deployment does not have. |
810+
| `400` | `INVALID_REQUEST` | Missing `userId`, or a missing/empty membership tier. |
811+
| `400` | `USER_NOT_FOUND` | No account with that `userId`. |
812+
| `400` | `USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION` | The account already holds a membership in that organization. |
813+
| `400` | `NO_ACTIVE_ORGANIZATION` | `organizationId` was omitted and the calling admin has no active organization to fall back to. |
814+
| `403` | `ORGANIZATION_MEMBERSHIP_LIMIT_REACHED` | The organization is at its membership limit (better-auth's `membershipLimit`, default 100). |
815+
816+
The last five are the vendor's own verdicts, forwarded verbatim rather than
817+
re-adjudicated.
818+
819+
<Callout type="info" title="On a walled deployment this is the only way to attach an existing user">
820+
Under a walled tenancy posture the other paths do not reach:
821+
822+
- **Create User** binds a new account to an organization only when there is an
823+
unambiguous one to bind to. Under the organization wall there is not, by
824+
design, so no membership row is written — see [Membership: how new users join
825+
an organization](/docs/deployment/tenancy-modes#membership-how-new-users-join-an-organization).
826+
- **Hand-writing the membership row** is not available: `sys_member` is managed
827+
by better-auth and generic CRUD on it is suppressed under the ADR-0010 lock.
828+
- **Invitations** need an email round-trip, which phone-number-only accounts
829+
cannot complete.
830+
831+
That leaves this route. An administrator who does not know it exists reasonably
832+
concludes the platform cannot attach an existing user to an organization at all.
833+
</Callout>
834+
759835
### Admin User Management
760836

761837
With `plugins: { admin: true }` (forced on when SCIM is enabled), platform
@@ -967,6 +1043,10 @@ All endpoints are available under `/api/v1/auth/*`:
9671043
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported)
9681044
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
9691045

1046+
#### Organization Membership (requires `plugins.organization`; platform-admin gated)
1047+
1048+
- `POST /api/v1/auth/organization/add-member` - Attach an **existing** account to an organization with no invitation ([details](#attaching-an-existing-user-to-an-organization)). An ObjectStack mount, not a better-auth route, and the only path that reaches this on a walled deployment.
1049+
9701050
For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs).
9711051

9721052
---

0 commit comments

Comments
 (0)