diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 3beb512fce..d0b9997e38 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -167,6 +167,18 @@ wall and did not get one, and the safe reading of that is "I don't know which or this user belongs to", not "everyone belongs to the only org I can see". So `defaultOrgId()` returns `null` there too. + +The `add-member` named above is `POST /api/v1/auth/organization/add-member`, an +ObjectStack mount wrapping better-auth's server-only `addMember`. Under a walled +posture it is the **only** way to attach an *existing* account to an +organization: the reconciler declines to guess a target org, generic `sys_member` +writes are suppressed under the ADR-0010 lock, and invitations need an email +round-trip that phone-number-only accounts cannot complete. Its admit set is +**platform admin only** — an organization owner or administrator is refused +`403 PERMISSION_DENIED`. See [Attaching an existing user to an +organization](/docs/permissions/authentication#attaching-an-existing-user-to-an-organization). + + ### Membership policy | Policy | Behavior | diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index dfe6b32550..a7aa962e34 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -756,6 +756,82 @@ more authority than the issuer has.** An issuer below `admin` grade may invite as plain `member` only, and no invitation may confer a tier above the issuer's own. +#### Attaching an existing user to an organization + +`POST /api/v1/auth/organization/add-member` attaches an **existing** account to +an organization directly — no invitation, no email round-trip. + +```http +POST /api/v1/auth/organization/add-member +{ + "userId": "usr_01HZX…", + "organizationId": "org_beta" +} +``` + +The body also carries the membership tier, under better-auth's own column name +for it — the same field the invitation example above sends (see [Membership +tiers are a closed list](#membership-tiers-are-a-closed-list)). It accepts a +single tier name or an array of them. `snake_case` spellings are accepted +alongside camelCase for the identifiers (`user_id`, `organization_id`, +`team_id`), so a value pasted straight out of the record grid works. + +`organizationId` may be omitted, in which case the membership lands in the +**calling admin's active organization**. `teamId` is optional and has no such +fallback: omit it and the member simply joins no team. + + +better-auth declares `addMember` as a **server-only** API with no HTTP path of +its own — measured on 1.7.1, where `addMember` builds its endpoint with no path +argument while every sibling in the same module (`/organization/remove-member`, +`/organization/list-members`, `/organization/leave`, …) passes one. So the +vendor's documentation does not list this URL, and an audit that enumerates +better-auth's mounted surface will not find it there either. ObjectStack mounts it ahead of the +catch-all and wraps the vendor's server-only endpoint; the vendor's own checks +(already-a-member, membership limit, team resolution, hooks) are not +reimplemented. + + +**The admit set is platform admin only.** An organization owner or organization +administrator is refused `403 PERMISSION_DENIED`, and that is deliberate under +ADR-0068: standing inside an organization is not standing on the platform. +Attaching someone to an organization without their consent is a +platform-operator action, and the vendor endpoint performs no authorization of +its own precisely because it was built to be called only from trusted server +code. + +Refusals, with both halves of the ADR-0112 envelope: + +| Status | `code` | When | +|---|---|---| +| `401` | `UNAUTHENTICATED` | No session. Checked before the body, so an anonymous caller with a malformed body still gets `401`. | +| `403` | `PERMISSION_DENIED` | Signed in, but not a platform admin — including organization owners and administrators. | +| `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. | +| `400` | `INVALID_REQUEST` | Missing `userId`, or a missing/empty membership tier. | +| `400` | `USER_NOT_FOUND` | No account with that `userId`. | +| `400` | `USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION` | The account already holds a membership in that organization. | +| `400` | `NO_ACTIVE_ORGANIZATION` | `organizationId` was omitted and the calling admin has no active organization to fall back to. | +| `403` | `ORGANIZATION_MEMBERSHIP_LIMIT_REACHED` | The organization is at its membership limit (better-auth's `membershipLimit`, default 100). | + +The last five are the vendor's own verdicts, forwarded verbatim rather than +re-adjudicated. + + +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). +- **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 + cannot complete. + +That leaves this route. An administrator who does not know it exists reasonably +concludes the platform cannot attach an existing user to an organization at all. + + ### Admin User Management 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/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) - `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early +#### Organization Membership (requires `plugins.organization`; platform-admin gated) + +- `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. + For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs). ---