From 0392d4104d716e6e2b31397c6af445c45afd6d0b Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:34:52 -0800 Subject: [PATCH] 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 --- apps/docs/content/run/self-host.mdx | 5 +- .../src/implementation/auth-options.ts | 11 +- .../src/implementation/root-organization.ts | 92 ++++++-- e2e/test-plan.ts | 35 +++ .../self-host-organization-creation.spec.ts | 206 ++++++++++++++++++ 5 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 e2e/tests/self-host-organization-creation.spec.ts diff --git a/apps/docs/content/run/self-host.mdx b/apps/docs/content/run/self-host.mdx index e5056dca8d..32da6a148a 100644 --- a/apps/docs/content/run/self-host.mdx +++ b/apps/docs/content/run/self-host.mdx @@ -122,7 +122,10 @@ docker stop executor-v2 ``` The first person to finish setup becomes the owner, and one organization is -created. After that, people join only through invitations. SSO signs in people +created. Its owners can create more organizations, and each creator becomes the +new organization's owner; owning another organization does not let anyone create +one. After that, people join only through invitations, each into the +organization that sent it. SSO signs in people who already belong to an organization and have connected SSO on their account's Security page; it never adds anyone to an organization. diff --git a/apps/hosted/self-host/src/implementation/auth-options.ts b/apps/hosted/self-host/src/implementation/auth-options.ts index e33a257785..81922a9bed 100644 --- a/apps/hosted/self-host/src/implementation/auth-options.ts +++ b/apps/hosted/self-host/src/implementation/auth-options.ts @@ -4,7 +4,7 @@ import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { organization } from "better-auth/plugins/organization"; import { Config, Effect, Option, Redacted, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; -import { selfHostRootSchema } from "./root-organization.ts"; +import { selfHostRoot } from "./root-organization.ts"; const httpsUrl = (message: string) => Schema.String.check( @@ -121,12 +121,17 @@ export const selfHostAuthOptions = ( discovery: typeof SsoDiscovery.Type | null, ) => { const base = authOptions(settings, ipAddressHeaders); + const root = selfHostRoot(); return { ...base, emailAndPassword: { enabled: true, disableSignUp: true }, plugins: [ - organization({ disableOrganizationDeletion: true, allowUserToCreateOrganization: false }), - selfHostRootSchema, + // Only an owner of the root organization creates another; its creator becomes its owner. + organization({ + disableOrganizationDeletion: true, + allowUserToCreateOrganization: (user) => root.ownsRoot(user.id), + }), + root.plugin, ...base.plugins, genericOAuth({ config: diff --git a/apps/hosted/self-host/src/implementation/root-organization.ts b/apps/hosted/self-host/src/implementation/root-organization.ts index 303254e9e8..68f536cecf 100644 --- a/apps/hosted/self-host/src/implementation/root-organization.ts +++ b/apps/hosted/self-host/src/implementation/root-organization.ts @@ -3,32 +3,92 @@ * record is written once, by setup or by the upgrade step below, and never inferred from dates or * names. A database without it has no root, and every root-only operation is refused. */ +import type { AuthContext } from "@better-auth/core"; import type { BetterAuthPlugin } from "better-auth"; +import { APIError } from "better-auth/api"; import { migrateProductSteps } from "@executor-js/hosted-server/migrations"; -import { Effect } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import { SqlClient } from "effect/sql"; /** The only ID the record ever has, so the primary key admits one root. */ export const rootRecordId = "root"; export const rootModel = "selfHostRoot"; -/** Must follow the organization plugin: Better Auth creates tables in plugin order. */ -export const selfHostRootSchema = { - id: "executor-self-host-root", - schema: { - [rootModel]: { - fields: { - organizationId: { - type: "string", - required: true, - unique: true, - references: { model: "organization", field: "id", onDelete: "restrict" }, +const Root = Schema.Struct({ organizationId: Schema.NonEmptyString }); +const Membership = Schema.Struct({ role: Schema.Literal("owner") }); + +const read = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: () => + new APIError("SERVICE_UNAVAILABLE", { message: "Authentication is unavailable. Try again." }), + }); + +/** Whether `userId` owns the root organization. Without a root record, no one does. */ +const ownsRoot = (adapter: AuthContext["adapter"], userId: string) => + Effect.gen(function* () { + const root = Schema.decodeUnknownOption(Root)( + yield* read(() => + adapter.findOne({ + model: rootModel, + where: [{ field: "id", value: rootRecordId }], + select: ["organizationId"], + }), + ), + ); + if (Option.isNone(root)) return false; + const member = yield* read(() => + adapter.findOne({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: root.value.organizationId }, + ], + select: ["role"], + }), + ); + return Option.isSome(Schema.decodeUnknownOption(Membership)(member)); + }); + +/** + * The root record's table and the check organization creation asks. It must follow the + * organization plugin: Better Auth creates tables in plugin order. + */ +export const selfHostRoot = () => { + let adapter: AuthContext["adapter"] | undefined; + return { + plugin: { + id: "executor-self-host-root", + init: (context) => { + adapter = context.adapter; + }, + schema: { + [rootModel]: { + fields: { + organizationId: { + type: "string", + required: true, + unique: true, + references: { model: "organization", field: "id", onDelete: "restrict" }, + }, + createdAt: { type: "date", required: true }, + }, }, - createdAt: { type: "date", required: true }, }, - }, - }, -} satisfies BetterAuthPlugin; + } satisfies BetterAuthPlugin, + ownsRoot: (userId: string) => + adapter === undefined + ? Promise.resolve(false) + : Effect.runPromiseExit(ownsRoot(adapter, userId)).then( + Exit.match({ + onSuccess: (owns) => owns, + onFailure: (cause) => { + throw Cause.squash(cause); + }, + }), + ), + }; +}; /** * An install from before the record had exactly one organization, its setup's, since nothing diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index a9e05a260a..79a4b2a80b 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -215,6 +215,41 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostOrganizationCreationAuthority: { + fixtures: "actors", + legacyStorage: true, + file: "self-host-organization-creation.spec.ts", + title: + "only an owner of the self-host root organization creates organizations; admins, members, outsiders, other owners, anonymous callers and bearer keys are refused", + targets: { + "self-host": scheduled, + cloud: na("Cloud lets every signed-in account create a team; its onboarding decides."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostOrganizationCreationBootstrap: { + legacyStorage: true, + file: "self-host-organization-creation.spec.ts", + title: + "self-host setup bootstraps one root owner once, stays closed as organizations are created, and keeps the root across a restart", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no instance setup."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostOrganizationCreationWithoutRoot: { + fixtures: "actors", + legacyStorage: true, + file: "self-host-organization-creation.spec.ts", + title: + "a self-host upgraded with one organization lets its owner create more, and one upgraded with several and no root record refuses everyone", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no root organization; every team is its own."), + local: na("Local has no hosted organizations."), + }, + }, registryMigration: { fixtures: "actors", legacyStorage: true, diff --git a/e2e/tests/self-host-organization-creation.spec.ts b/e2e/tests/self-host-organization-creation.spec.ts new file mode 100644 index 0000000000..a93da3626a --- /dev/null +++ b/e2e/tests/self-host-organization-creation.spec.ts @@ -0,0 +1,206 @@ +/** + * Self-host organization creation belongs to the owners of the root organization, the one setup + * created. Owning another organization grants nothing, and neither does a bearer credential: + * Better Auth's endpoint reads only a browser session. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Redacted, Schema } from "effect"; +import { randomUUID } from "node:crypto"; +import { Actors, password } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withCase, withHostedCase } from "../support/case.ts"; +import { Organization, Resource } from "../support/contracts.ts"; +import { legacyStorage } from "../support/legacy-storage.ts"; +import { serverControl } from "../support/server-control.ts"; +import { scenarios } from "../test-plan.ts"; + +const Access = Schema.Struct({ organization: Schema.String, role: Schema.String }); +const Key = Schema.Struct({ key: Schema.RedactedFromValue(Schema.String) }); +const Members = Schema.Struct({ + members: Schema.Array( + Schema.Struct({ role: Schema.String, user: Schema.Struct({ email: Schema.String }) }), + ), +}); +const Configuration = Schema.Struct({ setup: Schema.Boolean }); + +const create = (session: Session, name: string, headers?: Record) => + Effect.flatMap(Api, (api) => + api.request( + session, + "POST", + "/api/auth/organization/create", + { name, slug: `team-${randomUUID().slice(0, 8)}`, keepCurrentActiveOrganization: true }, + headers, + ), + ); + +const listed = (session: Session) => + Effect.flatMap(Api, (api) => + api + .request(session, "GET", "/api/auth/organization/list") + .pipe(Effect.flatMap((response) => body(Schema.Array(Organization), response))), + ); + +/** The database of an install from before the root record existed. */ +const beforeRootRecord = [ + { sql: `drop table "selfHostRoot"` }, + { sql: "drop table private_self_host_migrations" }, +] as const; + +layer(HostedLive, { excludeTestServices: true })("Self-host organization creation", (it) => { + it.effect(scenarios.selfHostOrganizationCreationAuthority.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors; + const created = yield* create(actors.owner, "Second team"); + expect(created.status).toBe(200); + const second = yield* body(Organization, created); + expect( + (yield* body( + Access, + yield* api.request(actors.owner, "GET", `/api/organizations/${second.id}/access`), + )).role, + ).toBe("owner"); + const members = yield* body( + Members, + yield* api.request( + actors.owner, + "GET", + `/api/auth/organization/list-members?organizationId=${second.id}`, + ), + ); + expect(members.members).toEqual([ + expect.objectContaining({ + role: "owner", + user: expect.objectContaining({ email: "owner@example.test" }), + }), + ]); + + expect((yield* create(actors.admin, "Admin's team")).status).toBe(403); + expect((yield* create(actors.member, "Member's team")).status).toBe(403); + const anonymous = yield* api.session(); + expect((yield* create(anonymous, "Anonymous team")).status).toBe(401); + const key = yield* body( + Key, + yield* api.request(actors.owner, "POST", "/api/auth/api-key/create", { + name: "Owner key", + }), + ); + expect( + (yield* create(anonymous, "Bearer team", { + authorization: `Bearer ${Redacted.value(key.key)}`, + })).status, + ).toBe(401); + + // Someone outside the root, invited into the new organization, cannot create one. + const invitation = yield* body( + Resource, + yield* api.request(actors.owner, "POST", "/api/auth/organization/invite-member", { + organizationId: second.id, + email: "outsider@example.test", + role: "admin", + }), + ); + const outsider = yield* api.session(); + expect( + (yield* api.request(outsider, "POST", "/api/auth/self-host/register", { + invitation: invitation.id, + email: "outsider@example.test", + name: "Out Sider", + password, + })).status, + ).toBe(200); + expect((yield* listed(outsider)).map((organization) => organization.id)).toEqual([ + second.id, + ]); + expect((yield* create(outsider, "Outsider's team")).status).toBe(403); + + // Owning an organization other than the root grants nothing either. + yield* legacyStorage([ + { + sql: `update "member" set "role" = 'owner' + where "organizationId" = $1 and "userId" = (select "id" from "user" where "email" = $2)`, + params: [second.id, "outsider@example.test"], + }, + ]); + yield* serverControl("start"); + expect( + (yield* body( + Access, + yield* api.request(outsider, "GET", `/api/organizations/${second.id}/access`), + )).role, + ).toBe("owner"); + expect((yield* create(outsider, "Second owner's team")).status).toBe(403); + + expect( + (yield* listed(actors.owner)).map((organization) => organization.id).toSorted(), + ).toEqual([actors.organization.id, second.id].toSorted()); + }), + ), + ); + + it.effect(scenarios.selfHostOrganizationCreationBootstrap.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api; + const owner = yield* api.session(); + const configuration = (session: Session) => + api + .request(session, "GET", "/api/auth/self-host/config") + .pipe(Effect.flatMap((response) => body(Configuration, response))); + expect((yield* configuration(owner)).setup).toBe(true); + const setup = (session: Session, email: string) => + api.request(session, "POST", "/api/auth/self-host/setup", { + name: "Setup Owner", + email, + password, + organizationName: "Root team", + }); + expect((yield* setup(owner, "owner@example.test")).status).toBe(200); + const [root] = yield* listed(owner); + if (root === undefined) return yield* Effect.die("Setup created no organization"); + expect((yield* configuration(owner)).setup).toBe(false); + const late = yield* api.session(); + expect((yield* setup(late, "late@example.test")).status).toBe(403); + + expect((yield* create(owner, "Second team")).status).toBe(200); + expect((yield* create(owner, "Third team")).status).toBe(200); + expect((yield* setup(late, "late@example.test")).status).toBe(403); + + const [records = []] = yield* legacyStorage([ + { sql: `select "id", "organizationId" from "selfHostRoot"` }, + ]); + expect(records).toEqual([{ id: "root", organizationId: root.id }]); + yield* serverControl("start"); + expect((yield* configuration(late)).setup).toBe(false); + expect((yield* setup(late, "late@example.test")).status).toBe(403); + expect((yield* create(owner, "Fourth team")).status).toBe(200); + expect(yield* listed(owner)).toHaveLength(4); + }), + ), + ); + + it.effect(scenarios.selfHostOrganizationCreationWithoutRoot.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors; + // An upgraded install with one organization records it, so its owner creates. + yield* legacyStorage(beforeRootRecord); + yield* serverControl("start"); + expect((yield* create(actors.owner, "Second team")).status).toBe(200); + + // One that already holds several has no root, so no one creates, not even setup's owner. + yield* legacyStorage(beforeRootRecord); + yield* serverControl("start"); + expect((yield* create(actors.owner, "Third team")).status).toBe(403); + yield* serverControl("restart"); + expect((yield* create(actors.owner, "Third team")).status).toBe(403); + expect(yield* listed(actors.owner)).toHaveLength(2); + }), + ), + ); +});