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
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>
Copy file name to clipboardExpand all lines: content/docs/permissions/authentication.mdx
+80Lines changed: 80 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -756,6 +756,82 @@ more authority than the issuer has.** An issuer below `admin` grade may invite
756
756
as plain `member` only, and no invitation may confer a tier above the issuer's
757
757
own.
758
758
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
+
<Callouttype="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
+
<Callouttype="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
+
759
835
### Admin User Management
760
836
761
837
With `plugins: { admin: true }` (forced on when SCIM is enabled), platform
@@ -967,6 +1043,10 @@ All endpoints are available under `/api/v1/auth/*`:
-`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
+
970
1050
For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs).
0 commit comments