Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/docs/content/run/self-host.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 8 additions & 3 deletions apps/hosted/self-host/src/implementation/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
92 changes: 76 additions & 16 deletions apps/hosted/self-host/src/implementation/root-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A>(run: () => Promise<A>) =>
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
Expand Down
35 changes: 35 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
206 changes: 206 additions & 0 deletions e2e/tests/self-host-organization-creation.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
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);
}),
),
);
});