Skip to content

Let self-host serve several organizations while creation stays off - #81

Merged
shuv1337 merged 1 commit into
v2from
feat/selfhost-multi-org
Nov 9, 2026
Merged

shuv1337 merged 1 commit into
v2from
feat/selfhost-multi-org

Conversation

@shuv1337

@shuv1337 shuv1337 commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Why

Self-host is getting real multi-organization support: only owners of the original (root) organization may create organizations, people join only by invitation, and SSO never adds anyone. Today a self-host database that holds a second organization refuses every sign-in, invitation and SSO login, because each path requires exactly one organization. This PR makes the server tolerate several organizations while creation stays disabled, and records the root organization so a later release can authorize creation against it.

Rollout gate: this release must ship and be verified in production before any organization-creation code (#82 and above) is activated there. A server older than this one locks every account out as soon as a second organization exists (measured below), so the release that tolerates several organizations has to be the one running, and the one any rollback lands on, before a second organization can be created.

What changed

  • registration.ts: removed instanceOrganization. A session needs a membership in any organization. /self-host/register admits into the invitation's own organization. SSO no longer auto-joins a verified, approved-domain address: it signs in only people who already belong to an organization and connected SSO on Security; anyone else gets sso_not_member ("Ask an administrator to invite you.").
  • New root-organization.ts: a Better Auth plugin table selfHostRoot with one row (id = 'root', organizationId unique, FK to organization with on delete restrict). Setup writes it in the same transaction that creates the first organization.
  • Migration private_self_host_migrations / 1_root_organization: records the root only when the database has exactly one organization; zero or several leave it unset (fail closed). Recorded once in its own journal, never replayed. Cloud does not load the plugin or the journal.
  • allowUserToCreateOrganization stays false.
  • Docs: self-host page and README describe invitations-only, SSO for existing members, and the rollback floor.

Schema compatibility

Additive only: one new table and one new journal table. The running server (411a959e) never reads either.

Measured by starting both server versions on the same scratch PGlite data directory:

Database Server Result
one organization, migrated by this branch 411a959e code starts, owner sign-in 200
two organizations 411a959e code owner sign-in 403 "The instance must have exactly one organization"
same two-organization database this stack owner sign-in 200

Verification

bun run check: exit 0.

bun run e2e:self-host --test-name '<titles>' on this branch:

  • self-host-many-organizations.spec.ts (new, legacyStorage fixtures): root recorded by setup; an upgrade records an install's only organization once and a restart does not replay it; several organizations without a root leave it unset; with two organizations members sign in, an invitation admits into the inviting organization only, an existing member accepts into a second organization with its role, someone with no membership cannot sign in, setup stays closed across a restart. 3/3 passed.
  • self-host-sso.spec.ts: 9/9 passed, including new "Self-host SSO admits no one automatically and refuses a verified, approved address without an invitation". The HTTPS and UserInfo scenarios now invite the person and connect SSO before the admitted sign-in.
  • Invitation, onboarding and password login scenarios: 7/7 passed.
  • Negative checks against origin/v2's registration.ts: the several-organization scenario fails at the owner's sign-in (expected 403 to be 200), and the no-auto-join scenario fails because the uninvited address is admitted.

The fork CI (fork-ci.yml) runs check and the security scenarios; it does not read the block below.

self-host-many-organizations.spec.ts
self-host-sso.spec.ts
invitation-redemption.spec.ts
invitation-roles.spec.ts
invitation-reaccept.spec.ts
invitation-security.spec.ts
invitation-dialog.spec.ts
self-host-onboarding.spec.ts
hosted.spec.ts

A self-host database refused every sign-in, invitation and SSO login once it
held a second organization, because each path required exactly one. Sessions
now need a membership in any organization, an invitation admits into its own
organization, and SSO signs in only people who already belong to one: it no
longer adds a verified, approved address to the instance's organization.

Setup records the organization it creates as the instance's root in a new
selfHostRoot table. The private_self_host_migrations step 1_root_organization
records an existing install's root when it has exactly one organization and
leaves it unset otherwise. Organization creation remains disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
shuv1337 added a commit that referenced this pull request Nov 6, 2026
--rollout pr81 adds two requirements to the general checks: the archive
holds exactly one organization, and private_hosted_migrations and
private_self_host_migrations are exactly the journals
7c49225 writes, with nothing missing,
extra or renamed. The report now counts organizations in every mode and,
for the rollout, lists missing pinned entries and counts unexpected ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
@shuv1337
shuv1337 merged commit 8e573ca into v2 Nov 9, 2026
4 checks passed
shuv1337 added a commit that referenced this pull request Nov 9, 2026
… SSO admission on the image (#85)

* Let self-host serve several organizations while creation stays off

A self-host database refused every sign-in, invitation and SSO login once it
held a second organization, because each path required exactly one. Sessions
now need a membership in any organization, an invitation admits into its own
organization, and SSO signs in only people who already belong to one: it no
longer adds a verified, approved address to the instance's organization.

Setup records the organization it creates as the instance's root in a new
selfHostRoot table. The private_self_host_migrations step 1_root_organization
records an existing install's root when it has exactly one organization and
leaves it unset otherwise. Organization creation remains disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let owners of the self-host root organization create organizations

Better Auth's create endpoint now asks whether the signed-in user owns the
root organization that setup recorded. Admins, members, people outside the
root, owners of other organizations, anonymous callers and bearer keys are
refused, and an install with no root record refuses everyone. The creator
becomes the new organization's owner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Run the cross-organization isolation scenarios on self-host

Self-host can now hold several organizations, so the scenarios proving that
apps, accounts, connections, groups, grants, dashboard batches and PAT MCP
calls stay inside their organization run there too. Self-host cannot remove
organizations, so those scenarios remove theirs only on Cloud; each self-host
scenario's server is discarded. The batch scenario's own organization is
created by the root owner, who invites the member as an administrator.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Offer organization creation in the self-host switcher to root owners

A signed-in user's GET /api/auth/self-host/capabilities answers whether they
may create organizations, from the same root-owner check the create endpoint
applies. The self-host switcher shows Create organization only when it says
so; the server still refuses everyone else.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Prove self-host setup races, setup rollback, forged creators and the switcher's unknown answer

Five callers racing to set up a fresh instance leave one owner, organization
and root record. A setup that fails at its last write leaves no rows and a
retry succeeds. Creation ignores a body naming the owner and refuses the
owner's own API key and MCP OAuth token, each shown valid where it was
issued. The switcher offers no creation while the capability answer is
pending or failed, and recovers on reload.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Prove SSO admission against the self-host image

The released-image suite gains two scenarios that run the native image with
SSO configured against the synthetic OpenID provider. A sidecar owns the
container network and forwards the provider's HTTPS address, so the image
trusts it through NODE_EXTRA_CA_CERTS. A verified, approved address without
a membership, an unverified address and an unapproved domain are refused
with their codes and no session or membership. An invited member who
connected SSO signs in, and is refused once removed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add a fail-closed offline gate for the self-host root record

scripts/inspect-self-host-root.ts loads an executor-host export archive into
an in-memory PGlite and compares it with the identity the operator recorded
before the upgrade: one organization ID and its full set of owners. It
resolves PGlite from the repository, refuses any version but the product's
0.5.8 before running it, and requires Postgres 18. It passes only when the
archive has exactly one root record, id root, naming that organization, the
organization's owners are exactly the recorded set, and the journal records
1 root_organization. It exits 1 when the gate fails, 2 for an invalid
invocation or baseline and 3 when the archive or PGlite cannot be used, and
prints a fixed report without rows, SQL errors or stack traces.

e2e:root-inspection runs it as a separate process over synthetic PGlite
archives for each pass, failure, baseline and error case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the #81 rollout profile to the self-host root gate

--rollout pr81 adds two requirements to the general checks: the archive
holds exactly one organization, and private_hosted_migrations and
private_self_host_migrations are exactly the journals
7c49225 writes, with nothing missing,
extra or renamed. The report now counts organizations in every mode and,
for the rollout, lists missing pinned entries and counts unexpected ones.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant