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); + }), + ), + ); +});