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
7 changes: 4 additions & 3 deletions apps/docs/content/run/self-host.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ These settings override the defaults:
| `DO_NOT_TRACK` or `EXECUTOR_DISABLE_ANALYTICS` | no | Set to `1` to turn off anonymous usage analytics and feedback. See [usage analytics](/run/usage-analytics). |
| `SSO_DISCOVERY_URL` | no | OIDC discovery document. Leave it empty to keep SSO off. |
| `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` | no | Credentials for that identity provider. |
| `SSO_ALLOWED_DOMAINS` | no | Comma-separated email domains allowed to join through SSO. |
| `SSO_ALLOWED_DOMAINS` | no | Comma-separated email domains whose members may sign in with SSO. |
| `EXECUTOR_TRACE_SAMPLE_RATE` | no | Fraction of ordinary request traces to record, from `0` to `1`. The default is `0.1`; `1` records every trace. See below. |
| `EXECUTOR_TRACE_SLOW_MS` | no | Record every trace that runs at least this many milliseconds. The default is `1000`. |

Expand Down Expand Up @@ -122,8 +122,9 @@ docker stop executor-v2
```

The first person to finish setup becomes the owner, and one organization is
created. After that, people join through invitations, or through SSO when you
have configured it.
created. After that, people join only through invitations. 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.

## Deploy on Railway

Expand Down
6 changes: 6 additions & 0 deletions apps/hosted/self-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ Motel volumes separate; the entrypoint prepares their ownership for the non-root
server user. Do not remove either volume during updates.
Product upgrades do not import old Motel data.

The database records the organization setup created as the instance's root.
An upgrade from an older release records it when the database has exactly one
organization, and leaves it unset otherwise. Releases before the one that added
the record refuse every sign-in once a database holds a second organization, so
do not roll back below it after one exists.

### Trace sampling

Recording every span made Motel the busiest process under load, so the product
Expand Down
2 changes: 2 additions & 0 deletions apps/hosted/self-host/src/implementation/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +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";

const httpsUrl = (message: string) =>
Schema.String.check(
Expand Down Expand Up @@ -125,6 +126,7 @@ export const selfHostAuthOptions = (
emailAndPassword: { enabled: true, disableSignUp: true },
plugins: [
organization({ disableOrganizationDeletion: true, allowUserToCreateOrganization: false }),
selfHostRootSchema,
...base.plugins,
genericOAuth({
config:
Expand Down
6 changes: 5 additions & 1 deletion apps/hosted/self-host/src/implementation/database-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeRegistryStorage } from "@executor-js/app-registry";
import { Effect, Layer, Redacted } from "effect";
import { AuthDatabase } from "../contracts/database.ts";
import { makeAuthDatabase } from "./auth-database.ts";
import { migrateSelfHostSteps } from "./root-organization.ts";
/** Acquire one engine and initialize both schemas before exposing services. */
export const selfHostDatabaseSchema = Layer.effect(
AuthDatabase,
Expand All @@ -21,7 +22,10 @@ export const selfHostDatabaseSchema = Layer.effect(
database,
secret: Redacted.value(settings.secret),
},
registry.migrate.pipe(Effect.mapError(migrationFailed("product"))),
registry.migrate.pipe(
Effect.mapError(migrationFailed("product")),
Effect.andThen(migrateSelfHostSteps),
),
);
return database;
}),
Expand Down
107 changes: 30 additions & 77 deletions apps/hosted/self-host/src/implementation/registration.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
/** Atomic setup and invitation redemption over Better Auth's transaction adapter. */
import {
defineRequestState,
getCurrentAdapter,
runWithTransaction,
} from "@better-auth/core/context";
import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
import type {
BetterAuthOptions,
BetterAuthPlugin,
Expand All @@ -15,14 +11,8 @@ import { SsoRefusal } from "@executor-js/hosted-self-host-web/sso";
import { Cause, Effect, Exit, Schema } from "effect";
import { z } from "zod";
import type { selfHostAuthSettings } from "./auth-options.ts";
import { rootModel, rootRecordId } from "./root-organization.ts";

const admittedSsoUser = defineRequestState<boolean>(() => false);

const User = Schema.Struct({
id: Schema.String,
email: Schema.String,
emailVerified: Schema.Boolean,
});
const Organization = Schema.Struct({ id: Schema.String });
const Invitation = Schema.Struct({
id: Schema.String,
Expand Down Expand Up @@ -70,21 +60,12 @@ const adapterFor = (ctx: GenericEndpointContext) =>
call(() => getCurrentAdapter(ctx.context.adapter));
const isSsoCallback = (ctx: GenericEndpointContext) =>
ctx.path === "/callback/:id" && ctx.params?.id === "sso";
const notMember = "Your account is not a member of this instance.";

const instanceOrganization = (ctx: GenericEndpointContext) =>
Effect.gen(function* () {
const adapter = yield* adapterFor(ctx);
const organizations = yield* call(() => adapter.findMany({ model: "organization", limit: 2 }));
if (organizations.length !== 1) {
const message = "The instance must have exactly one organization. Contact its administrator.";
return yield* isSsoCallback(ctx)
? refuseSso(SsoRefusal.instanceNotReady, message)
: deny(message);
}
return yield* decode(Organization, organizations[0]);
});

/** Reject non-SSO user creation; only verified, allowlisted OIDC users can join automatically. */
/**
* Invitations are the only way in after setup. SSO signs in people who already belong to an
* organization; it never creates an account or a membership.
*/
export const selfHostUserHooks = (settings: Effect.Success<typeof selfHostAuthSettings>) =>
({
user: {
Expand All @@ -95,7 +76,7 @@ export const selfHostUserHooks = (settings: Effect.Success<typeof selfHostAuthSe
if (ctx?.path === "/self-host/setup" || ctx?.path === "/self-host/register") return;
if (!ctx || !isSsoCallback(ctx) || settings.sso === null)
return yield* deny("Create the first account through setup, or use an invitation.");
// Only an address the provider vouches for joins; an absent claim is not a yes.
// A provider problem is explained before the missing membership is.
if (!user.emailVerified)
return yield* refuseSso(
SsoRefusal.emailUnverified,
Expand All @@ -107,8 +88,13 @@ export const selfHostUserHooks = (settings: Effect.Success<typeof selfHostAuthSe
SsoRefusal.domainNotAllowed,
"Use an account from an approved SSO domain.",
);
yield* instanceOrganization(ctx);
yield* call(() => admittedSsoUser.set(true));
const adapter = yield* adapterFor(ctx);
if ((yield* call(() => adapter.count({ model: "organization" }))) === 0)
return yield* refuseSso(
SsoRefusal.instanceNotReady,
"This instance is not set up yet.",
);
return yield* refuseSso(SsoRefusal.notMember, notMember);
}),
),
},
Expand All @@ -120,55 +106,17 @@ export const selfHostUserHooks = (settings: Effect.Success<typeof selfHostAuthSe
Effect.gen(function* () {
if (!ctx) return yield* deny("A sign-in request is required.");
const adapter = yield* adapterFor(ctx);
const org = yield* instanceOrganization(ctx);
const member = yield* call(() =>
adapter.findOne({
model: "member",
where: [
{ field: "userId", value: session.userId },
{ field: "organizationId", value: org.id },
],
where: [{ field: "userId", value: session.userId }],
select: ["id"],
}),
);
if (member === null) {
// Re-check the policy on every SSO login; no email/password login can auto-join.
if (!isSsoCallback(ctx) || settings.sso === null)
return yield* deny("Your account is not a member of this instance.");
if (!(yield* call(() => admittedSsoUser.get())))
return yield* refuseSso(
SsoRefusal.notMember,
"Your account is not a member of this instance.",
);
const user = yield* call(() =>
adapter.findOne({
model: "user",
where: [{ field: "id", value: session.userId }],
}),
).pipe(Effect.flatMap((value) => decode(User, value)));
const domain = user.email.toLowerCase().split("@")[1];
if (!user.emailVerified)
return yield* refuseSso(
SsoRefusal.emailUnverified,
"The identity provider did not verify this email address.",
);
if (!domain || !settings.sso.allowedDomains.includes(domain))
return yield* refuseSso(
SsoRefusal.domainNotAllowed,
"Use an account from an approved SSO domain.",
);
yield* call(() =>
adapter.create({
model: "member",
data: {
organizationId: org.id,
userId: user.id,
role: "member",
createdAt: new Date(),
},
}),
);
}
return { data: { ...session, activeOrganizationId: org.id } };
if (member === null)
return yield* isSsoCallback(ctx)
? refuseSso(SsoRefusal.notMember, notMember)
: deny(notMember);
}),
),
},
Expand Down Expand Up @@ -209,10 +157,16 @@ const register = (
},
}),
).pipe(Effect.flatMap((value) => decode(Organization, value)));
yield* call(() =>
adapter.create({
model: rootModel,
data: { id: rootRecordId, organizationId: org.id, createdAt: new Date() },
forceAllowId: true,
}),
);
organizationId = org.id;
role = "owner";
} else {
const org = yield* instanceOrganization(ctx);
const invitation = yield* call(() =>
adapter.findOne({
model: "invitation",
Expand All @@ -222,13 +176,12 @@ const register = (
if (
invitation.status !== "pending" ||
invitation.expiresAt.getTime() <= Date.now() ||
invitation.email.toLowerCase() !== email ||
invitation.organizationId !== org.id
invitation.email.toLowerCase() !== email
)
return yield* deny(
"This invitation is invalid, expired, or for a different email.",
);
organizationId = org.id;
organizationId = invitation.organizationId;
role = invitation.role;
yield* call(() =>
adapter.update({
Expand Down
47 changes: 47 additions & 0 deletions apps/hosted/self-host/src/implementation/root-organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* The instance's root organization: the one setup created. Its owners control the instance. The
* 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 { BetterAuthPlugin } from "better-auth";
import { migrateProductSteps } from "@executor-js/hosted-server/migrations";
import { Effect } 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" },
},
createdAt: { type: "date", required: true },
},
},
},
} satisfies BetterAuthPlugin;

/**
* An install from before the record had exactly one organization, its setup's, since nothing
* else could create one. Any other count leaves the root unset.
*/
const recordSoleOrganization = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`insert into ${sql(rootModel)} ("id", "organizationId", "createdAt")
select ${rootRecordId}, "id", now() from "organization"
where (select count(*) from "organization") = 1
on conflict do nothing`;
});

export const migrateSelfHostSteps = migrateProductSteps("private_self_host_migrations", {
"1_root_organization": recordSoleOrganization,
});
2 changes: 1 addition & 1 deletion apps/hosted/self-host/web/src/contracts/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const explanations: Readonly<Record<string, string>> = {
[SsoRefusal.domainNotAllowed]:
"This email is not from a domain approved for SSO on this instance. Use an approved account or contact your administrator.",
[SsoRefusal.notMember]:
"Your account is not a member of this instance. Contact your administrator.",
"Your account is not a member of this instance. Ask an administrator to invite you.",
[SsoRefusal.instanceNotReady]:
"This instance is not set up yet. Ask your administrator to finish setup.",
// Better Auth links SSO only to an account whose email was verified, and password accounts
Expand Down
2 changes: 1 addition & 1 deletion apps/hosted/server/src/implementation/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const migrationFailed =
* transaction. The caller owns startup access or the hosted migration lock.
*/
export const migrateProductSteps = (
table: "private_hosted_migrations" | "private_cloud_migrations",
table: "private_hosted_migrations" | "private_cloud_migrations" | "private_self_host_migrations",
steps: Record<string, Effect.Effect<void, unknown, SqlClient.SqlClient>>,
) =>
Effect.gen(function* () {
Expand Down
46 changes: 46 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,42 @@ export const scenarios = plan({
local: na("Local does not publish apps."),
},
},
selfHostRootRecord: {
fixtures: "actors",
legacyStorage: true,
file: "self-host-many-organizations.spec.ts",
title:
"self-host setup records its organization as the root, and an upgrade records an install's only organization once",
targets: {
"self-host": scheduled,
cloud: na("Cloud has no root organization; every team is its own."),
local: na("Local has no hosted organizations."),
},
},
selfHostRootUnsetAmongSeveral: {
fixtures: "actors",
legacyStorage: true,
file: "self-host-many-organizations.spec.ts",
title:
"a self-host upgraded with several organizations and no root record leaves the root unset and keeps serving each",
targets: {
"self-host": scheduled,
cloud: na("Cloud has no root organization; every team is its own."),
local: na("Local has no hosted organizations."),
},
},
selfHostSeveralOrganizations: {
fixtures: "actors",
legacyStorage: true,
file: "self-host-many-organizations.spec.ts",
title:
"a self-host with several organizations signs in their members, admits invitations into the inviting organization and refuses anyone without one",
targets: {
"self-host": scheduled,
cloud: na("Cloud invitations need a verified recipient instead of password registration."),
local: na("Local has no hosted organizations."),
},
},
registryMigration: {
fixtures: "actors",
legacyStorage: true,
Expand Down Expand Up @@ -5605,6 +5641,16 @@ export const scenarios = plan({
local: na("Local has no hosted sign-in."),
},
},
selfHostSsoNoAutoJoin: {
file: "self-host-sso.spec.ts",
title:
"Self-host SSO admits no one automatically and refuses a verified, approved address without an invitation",
targets: {
"self-host": scheduled,
cloud: na("Cloud customer SSO has its own OIDC scenario."),
local: na("Local has no sign-in or SSO."),
},
},
selfHostSsoUnverifiedEmail: {
file: "self-host-sso.spec.ts",
title: "Self-host SSO refuses an email the provider does not verify and says why",
Expand Down
Loading
Loading