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