From 7c4922535596a6fe86938a8acbe0078107ce2bca Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:30:45 -0800 Subject: [PATCH 1/2] Let self-host serve several organizations while creation stays off A self-host database refused every sign-in, invitation and SSO login once it held a second organization, because each path required exactly one. Sessions now need a membership in any organization, an invitation admits into its own organization, and SSO signs in only people who already belong to one: it no longer adds a verified, approved address to the instance's organization. Setup records the organization it creates as the instance's root in a new selfHostRoot table. The private_self_host_migrations step 1_root_organization records an existing install's root when it has exactly one organization and leaves it unset otherwise. Organization creation remains disabled. Co-authored-by: Cursor --- apps/docs/content/run/self-host.mdx | 7 +- apps/hosted/self-host/README.md | 6 + .../src/implementation/auth-options.ts | 2 + .../src/implementation/database-schema.ts | 6 +- .../src/implementation/registration.ts | 107 +++------ .../src/implementation/root-organization.ts | 47 ++++ .../hosted/self-host/web/src/contracts/sso.ts | 2 +- .../server/src/implementation/migrations.ts | 2 +- e2e/test-plan.ts | 46 ++++ .../self-host-many-organizations.spec.ts | 214 ++++++++++++++++++ e2e/tests/self-host-sso.spec.ts | 123 +++++++++- 11 files changed, 477 insertions(+), 85 deletions(-) create mode 100644 apps/hosted/self-host/src/implementation/root-organization.ts create mode 100644 e2e/tests/self-host-many-organizations.spec.ts diff --git a/apps/docs/content/run/self-host.mdx b/apps/docs/content/run/self-host.mdx index 50cb50a526..e5056dca8d 100644 --- a/apps/docs/content/run/self-host.mdx +++ b/apps/docs/content/run/self-host.mdx @@ -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`. | @@ -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 diff --git a/apps/hosted/self-host/README.md b/apps/hosted/self-host/README.md index 27889e6d1a..a9f9597fc2 100644 --- a/apps/hosted/self-host/README.md +++ b/apps/hosted/self-host/README.md @@ -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 diff --git a/apps/hosted/self-host/src/implementation/auth-options.ts b/apps/hosted/self-host/src/implementation/auth-options.ts index df239509f9..e33a257785 100644 --- a/apps/hosted/self-host/src/implementation/auth-options.ts +++ b/apps/hosted/self-host/src/implementation/auth-options.ts @@ -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( @@ -125,6 +126,7 @@ export const selfHostAuthOptions = ( emailAndPassword: { enabled: true, disableSignUp: true }, plugins: [ organization({ disableOrganizationDeletion: true, allowUserToCreateOrganization: false }), + selfHostRootSchema, ...base.plugins, genericOAuth({ config: diff --git a/apps/hosted/self-host/src/implementation/database-schema.ts b/apps/hosted/self-host/src/implementation/database-schema.ts index 45b44fa1a8..3b40c71899 100644 --- a/apps/hosted/self-host/src/implementation/database-schema.ts +++ b/apps/hosted/self-host/src/implementation/database-schema.ts @@ -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, @@ -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; }), diff --git a/apps/hosted/self-host/src/implementation/registration.ts b/apps/hosted/self-host/src/implementation/registration.ts index 411874678d..c1da1f3fdf 100644 --- a/apps/hosted/self-host/src/implementation/registration.ts +++ b/apps/hosted/self-host/src/implementation/registration.ts @@ -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, @@ -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(() => 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, @@ -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) => ({ user: { @@ -95,7 +76,7 @@ export const selfHostUserHooks = (settings: Effect.Success 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); }), ), }, @@ -120,55 +106,17 @@ export const selfHostUserHooks = (settings: Effect.Success 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); }), ), }, @@ -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", @@ -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({ diff --git a/apps/hosted/self-host/src/implementation/root-organization.ts b/apps/hosted/self-host/src/implementation/root-organization.ts new file mode 100644 index 0000000000..303254e9e8 --- /dev/null +++ b/apps/hosted/self-host/src/implementation/root-organization.ts @@ -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, +}); diff --git a/apps/hosted/self-host/web/src/contracts/sso.ts b/apps/hosted/self-host/web/src/contracts/sso.ts index 2bce3d653a..39a82b02bc 100644 --- a/apps/hosted/self-host/web/src/contracts/sso.ts +++ b/apps/hosted/self-host/web/src/contracts/sso.ts @@ -15,7 +15,7 @@ const explanations: Readonly> = { [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 diff --git a/apps/hosted/server/src/implementation/migrations.ts b/apps/hosted/server/src/implementation/migrations.ts index 95733bbab1..4edda03a7a 100644 --- a/apps/hosted/server/src/implementation/migrations.ts +++ b/apps/hosted/server/src/implementation/migrations.ts @@ -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>, ) => Effect.gen(function* () { diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 52bbe8dfc6..a9e05a260a 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -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, @@ -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", diff --git a/e2e/tests/self-host-many-organizations.spec.ts b/e2e/tests/self-host-many-organizations.spec.ts new file mode 100644 index 0000000000..ea625269a3 --- /dev/null +++ b/e2e/tests/self-host-many-organizations.spec.ts @@ -0,0 +1,214 @@ +/** + * A self-host database can hold several organizations before this server lets anyone create one, + * as after a rollback from a release that did. Their rows are written directly. The root record + * names the organization setup created; an upgrade records an install's only organization and + * never picks one among several. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { Actors, password } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, 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 Root = Schema.Array(Schema.Struct({ id: Schema.String, organizationId: Schema.String })); +const Journal = Schema.Array(Schema.Struct({ name: Schema.String })); +const Access = Schema.Struct({ organization: Schema.String, role: Schema.String }); + +const rootQueries = [ + { sql: `select "id", "organizationId", "createdAt" from "selfHostRoot"` }, + { sql: "select name from private_self_host_migrations order by migration_id" }, +] as const; + +const decodeRoot = ([root = [], journal = []]: ReadonlyArray< + ReadonlyArray>> +>) => + Effect.gen(function* () { + return { + root: yield* Schema.decodeUnknownEffect(Root)(root), + createdAt: root.map((row) => row.createdAt), + journal: (yield* Schema.decodeUnknownEffect(Journal)(journal)).map((row) => row.name), + }; + }); + +/** 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; + +const secondOrganization = (ownerEmail: string) => + [ + { + sql: `insert into "organization" ("id", "name", "slug", "createdAt") values ('org-second', 'Second team', 'second-team', now())`, + }, + { + sql: `insert into "member" ("id", "organizationId", "userId", "role", "createdAt") + select 'member-second-owner', 'org-second', "id", 'owner', now() from "user" where "email" = $1`, + params: [ownerEmail], + }, + ] as const; + +const signIn = (email: string) => + Effect.gen(function* () { + const api = yield* Api; + const session = yield* api.session(); + const response = yield* api.request(session, "POST", "/api/auth/sign-in/email", { + email, + password, + }); + return { session, status: response.status }; + }); + +const access = (session: Session, organization: string) => + Effect.flatMap(Api, (api) => + api.request(session, "GET", `/api/organizations/${organization}/access`), + ); + +layer(HostedLive, { excludeTestServices: true })("Self-host with several organizations", (it) => { + it.effect(scenarios.selfHostRootRecord.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors; + const root = { id: "root", organizationId: actors.organization.id }; + const setUp = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(setUp.root).toEqual([root]); + expect(setUp.journal).toEqual(["root_organization"]); + + yield* legacyStorage(beforeRootRecord); + yield* serverControl("start"); + const upgraded = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(upgraded.root).toEqual([root]); + expect(upgraded.journal).toEqual(["root_organization"]); + + yield* serverControl("start"); + const repeated = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(repeated).toEqual(upgraded); + yield* serverControl("start"); + expect((yield* access(actors.owner, actors.organization.id)).status).toBe(200); + }), + ), + ); + + it.effect(scenarios.selfHostRootUnsetAmongSeveral.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors; + yield* legacyStorage([...beforeRootRecord, ...secondOrganization("owner@example.test")]); + yield* serverControl("start"); + const upgraded = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(upgraded.root).toEqual([]); + expect(upgraded.journal).toEqual(["root_organization"]); + yield* serverControl("start"); + expect((yield* access(actors.owner, actors.organization.id)).status).toBe(200); + expect((yield* access(actors.owner, "org-second")).status).toBe(200); + }), + ), + ); + + it.effect(scenarios.selfHostSeveralOrganizations.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors; + const root = actors.organization.id; + yield* legacyStorage(secondOrganization("owner@example.test")); + yield* serverControl("start"); + + const owner = yield* signIn("owner@example.test"); + expect(owner.status).toBe(200); + const listed = yield* body( + Schema.Array(Organization), + yield* api.request(owner.session, "GET", "/api/auth/organization/list"), + ); + expect(listed.map((organization) => organization.id).toSorted()).toEqual( + [root, "org-second"].toSorted(), + ); + expect((yield* access(actors.member, root)).status).toBe(200); + + // A new person joins the organization whose invitation they redeem, and only that one. + const invitation = yield* body( + Resource, + yield* api.request(owner.session, "POST", "/api/auth/organization/invite-member", { + organizationId: "org-second", + email: "newcomer@example.test", + role: "member", + }), + ); + const newcomer = yield* api.session(); + const joined = yield* api.request(newcomer, "POST", "/api/auth/self-host/register", { + invitation: invitation.id, + email: "newcomer@example.test", + name: "New Comer", + password, + }); + expect(joined.status).toBe(200); + const newcomerOrganizations = yield* body( + Schema.Array(Organization), + yield* api.request(newcomer, "GET", "/api/auth/organization/list"), + ); + expect(newcomerOrganizations.map((organization) => organization.id)).toEqual([ + "org-second", + ]); + expect( + yield* body(Access, yield* access(newcomer, "org-second")).pipe( + Effect.map((value) => value.role), + ), + ).toBe("member"); + expect((yield* access(newcomer, root)).status).toBe(403); + + // A member of the root accepts an invitation into the second organization with its role. + const adminInvitation = yield* body( + Resource, + yield* api.request(owner.session, "POST", "/api/auth/organization/invite-member", { + organizationId: "org-second", + email: "admin@example.test", + role: "admin", + }), + ); + const accepted = yield* api.request( + actors.admin, + "POST", + "/api/auth/organization/accept-invitation", + { invitationId: adminInvitation.id }, + ); + expect(accepted.status).toBe(200); + expect((yield* body(Access, yield* access(actors.admin, "org-second"))).role).toBe("admin"); + expect((yield* body(Access, yield* access(actors.admin, root))).role).toBe("admin"); + + // Someone who belongs to no organization cannot sign in. + const removed = yield* api.request( + owner.session, + "POST", + "/api/auth/organization/remove-member", + { organizationId: root, memberIdOrEmail: "member@example.test" }, + ); + expect(removed.status).toBe(200); + expect((yield* signIn("member@example.test")).status).toBe(403); + + // Setup stays closed however many organizations exist, across a restart. + const config = yield* body( + Schema.Struct({ setup: Schema.Boolean }), + yield* api.request(newcomer, "GET", "/api/auth/self-host/config"), + ); + expect(config.setup).toBe(false); + const anonymous = yield* api.session(); + const setup = yield* api.request(anonymous, "POST", "/api/auth/self-host/setup", { + name: "Intruder", + email: "intruder@example.test", + password, + organizationName: "Takeover", + }); + expect(setup.status).toBe(403); + yield* serverControl("restart"); + expect((yield* signIn("owner@example.test")).status).toBe(200); + }), + ), + ); +}); diff --git a/e2e/tests/self-host-sso.spec.ts b/e2e/tests/self-host-sso.spec.ts index f4d3d0cada..64ec2e1887 100644 --- a/e2e/tests/self-host-sso.spec.ts +++ b/e2e/tests/self-host-sso.spec.ts @@ -7,6 +7,7 @@ import { expect, layer } from "@effect/vitest"; import { Effect, FileSystem, Schema } from "effect"; import type { Page } from "playwright"; +import { password } from "../support/actors.ts"; import { Browser } from "../support/browser.ts"; import { TestLive, withCase } from "../support/case.ts"; import { startControlledSelfHost } from "../support/managed-server.ts"; @@ -56,7 +57,7 @@ const startSsoInstance = (mode: IdentityMode) => }); yield* serverControl("start"); }).pipe(Effect.provideService(Target, instance)); - // SSO admits members into the instance's organization, so the operator sets it up first. + // SSO signs in only people an organization admitted, so the operator sets one up first. expect( yield* browser.use("Set up the instance", (page) => page @@ -122,6 +123,71 @@ const expectExplainedRejection = (origin: string, step: string, explanation: str expect(yield* session(origin)).toBeNull(); }); +const notMember = + "Your account is not a member of this instance. Ask an administrator to invite you."; + +/** Invitations are the only way in: the owner, still signed in from setup, invites `email`. */ +const invite = (origin: string, email: string) => + Effect.gen(function* () { + const browser = yield* Browser; + const [organization] = yield* browser + .use("Read the organization", (page) => + page + .context() + .request.get(`${origin}/api/auth/organization/list`) + .then((response) => response.json()), + ) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.String }))), + ), + ); + if (organization === undefined) return yield* Effect.die("Setup created no organization"); + return yield* browser + .use("Invite the SSO member", (page) => + page + .context() + .request.post(`${origin}/api/auth/organization/invite-member`, { + headers: { origin }, + data: { organizationId: organization.id, email, role: "member" }, + }) + .then((response) => response.json()), + ) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.String })))); + }); + +/** The invited person registers with a password, then connects SSO, signed in as `mode` says. */ +const connectInvited = ( + origin: string, + idp: Effect.Success>, + invitation: { readonly id: string }, + email: string, + mode: IdentityMode, +) => + Effect.gen(function* () { + const browser = yield* Browser; + yield* browser.use("Sign out", (page) => page.context().clearCookies()); + expect( + yield* browser.use("The invited member registers", (page) => + page + .context() + .request.post(`${origin}/api/auth/self-host/register`, { + headers: { origin }, + data: { invitation: invitation.id, email, name: "SSO Member", password }, + }) + .then((response) => response.status()), + ), + ).toBe(200); + yield* idp.configure(email, mode); + yield* browser.use("Open Security", (page) => page.goto(`${origin}/account/security`)); + yield* browser.use("Connect the member's SSO account", (page) => + page.getByRole("button", { name: "Connect SSO", exact: true }).click(), + ); + yield* browser.use("Security shows SSO connected", (page) => + page.getByText("Connected. You can sign in with SSO or with your password.").waitFor(), + ); + }); + const unverified = "Your identity provider did not confirm that this email address is verified. Ask your administrator to have the provider mark it verified."; @@ -185,12 +251,13 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { Effect.gen(function* () { const browser = yield* Browser; const { idp, origin } = yield* startSsoInstance("valid"); + const invitation = yield* invite(origin, "member@sso.example.test"); // Keys are fetched on first use and then cached, so the key redirect comes first. yield* idp.configure("jwks-redirect@sso.example.test", "jwks-redirect"); yield* expectRejectedSignIn(origin, "Signing keys redirected to cleartext"); yield* idp.configure("token-redirect@sso.example.test", "token-redirect"); yield* expectRejectedSignIn(origin, "Token exchange redirected to cleartext"); - yield* idp.configure("member@sso.example.test", "valid"); + yield* connectInvited(origin, idp, invitation, "member@sso.example.test", "valid"); yield* continueWithSso(origin, "HTTPS provider"); yield* browser.use("The admitted member opens the organization", (page) => page.waitForURL(`${origin}/org/*/**`), @@ -208,13 +275,23 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { Effect.gen(function* () { const browser = yield* Browser; const { idp, origin } = yield* startSsoInstance("valid"); + const invitation = yield* invite(origin, "member@sso.example.test"); // UserInfo about another subject does not verify this one's address. yield* idp.configure("other-subject@sso.example.test", "userinfo-other-subject"); yield* expectExplainedRejection(origin, "UserInfo for another subject", unverified); yield* browser.checkpoint("SSO refused when UserInfo names another subject"); + // Verification passes, so the refusal is the missing membership, not the address. yield* idp.configure("member@sso.example.test", "userinfo-verified"); + yield* expectExplainedRejection(origin, "Verified at UserInfo, not invited", notMember); + yield* connectInvited( + origin, + idp, + invitation, + "member@sso.example.test", + "userinfo-verified", + ); yield* continueWithSso(origin, "Email verified only at UserInfo"); yield* browser.use("The member leaves sign-in", (page) => page.waitForURL( @@ -232,6 +309,48 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { ), ); + it.effect(scenarios.selfHostSsoNoAutoJoin.title, (context) => + withCase( + context, + Effect.gen(function* () { + const browser = yield* Browser; + const { origin } = yield* startSsoInstance("valid"); + yield* expectExplainedRejection( + origin, + "Verified, approved domain, not invited", + notMember, + ); + yield* browser.checkpoint("SSO refuses a verified, approved address without a membership"); + // The refusal created nothing, so the next attempt is refused the same way. + yield* expectExplainedRejection(origin, "The same person tries again", notMember); + const members = yield* browser.use("The owner lists the members", (page) => + page + .context() + .request.post(`${origin}/api/auth/sign-in/email`, { + headers: { origin }, + data: { email: "owner@sso.example.test", password }, + }) + .then(() => page.context().request.get(`${origin}/api/auth/organization/list`)) + .then((response) => response.json()) + .then((organizations: ReadonlyArray<{ readonly id: string }>) => + page + .context() + .request.get( + `${origin}/api/auth/organization/list-members?organizationId=${organizations[0]?.id ?? ""}`, + ), + ) + .then((response) => response.json()), + ); + expect(members).toEqual( + expect.objectContaining({ + total: 1, + members: [expect.objectContaining({ role: "owner" })], + }), + ); + }), + ), + ); + it.effect(scenarios.selfHostSsoUnverifiedEmail.title, (context) => withCase( context, From 0392d4104d716e6e2b31397c6af445c45afd6d0b Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:34:52 -0800 Subject: [PATCH 2/2] 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); + }), + ), + ); +});