From 7c4922535596a6fe86938a8acbe0078107ce2bca Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:30:45 -0800 Subject: [PATCH 1/8] 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/8] 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); + }), + ), + ); +}); From 340e8241280caba2b0989c829e5998ca6f52c23f Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:37:17 -0800 Subject: [PATCH 3/8] Run the cross-organization isolation scenarios on self-host Self-host can now hold several organizations, so the scenarios proving that apps, accounts, connections, groups, grants, dashboard batches and PAT MCP calls stay inside their organization run there too. Self-host cannot remove organizations, so those scenarios remove theirs only on Cloud; each self-host scenario's server is discarded. The batch scenario's own organization is created by the root owner, who invites the member as an administrator. Co-authored-by: Cursor --- e2e/test-plan.ts | 12 +--- e2e/tests/dashboard-batch-isolation.spec.ts | 68 +++++++++++++++------ e2e/tests/groups.spec.ts | 15 +++-- e2e/tests/pat-mcp.spec.ts | 5 +- e2e/tests/resource-isolation.spec.ts | 17 +++--- 5 files changed, 74 insertions(+), 43 deletions(-) diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 79a4b2a80b..d4bb7674cf 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -2520,9 +2520,7 @@ export const scenarios = plan({ file: "resource-isolation.spec.ts", title: "Group resource grants and account connections cannot cross organizations", targets: { - "self-host": na( - "Self-host permits one organization; real cross-organization grants are a Cloud scenario.", - ), + "self-host": scheduled, cloud: scheduled, local: na("Local has no organization sharing policy."), }, @@ -3233,9 +3231,7 @@ export const scenarios = plan({ title: "Group identities and memberships cannot cross organization boundaries", targets: { cloud: scheduled, - "self-host": na( - "Self-host permits only one organization; foreign organization references are checked in the shared groups scenario.", - ), + "self-host": scheduled, local: na("Local has no organizations or groups."), }, }, @@ -7326,9 +7322,7 @@ export const scenarios = plan({ file: "pat-mcp.spec.ts", title: "PAT MCP calls keep their organization when its slug is renamed mid-request", targets: { - "self-host": na( - "Self-host permits one organization, which every parallel scenario shares; renaming it would move theirs.", - ), + "self-host": scheduled, cloud: scheduled, local: na("Local has no organizations."), }, diff --git a/e2e/tests/dashboard-batch-isolation.spec.ts b/e2e/tests/dashboard-batch-isolation.spec.ts index 40891a4ee3..6c7f14b73b 100644 --- a/e2e/tests/dashboard-batch-isolation.spec.ts +++ b/e2e/tests/dashboard-batch-isolation.spec.ts @@ -4,7 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/http"; import { randomUUID } from "node:crypto"; import { Actors } from "../support/actors.ts"; import { Api, body, type Session } from "../support/api.ts"; -import { Organization } from "../support/contracts.ts"; +import { Organization, Resource } from "../support/contracts.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { Target } from "../support/platform.ts"; import { batchPath } from "../support/read-batches.ts"; @@ -213,9 +213,8 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i expect(yield* roleOf(demoted[1])).toBe("member"); expect(demoted[2]?.status).toBe(403); - if (!cloud) return; - // Self-host has one organization. On Cloud, one batch spans organizations where the caller - // has different roles, one it does not belong to, and aliases of each. + // One batch spans organizations where the caller has different roles, one it does not + // belong to, and aliases of each. const suffix = randomUUID().slice(0, 8); const foreign = yield* body( Organization, @@ -225,22 +224,47 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i keepCurrentActiveOrganization: true, }), ); - yield* Effect.addFinalizer(() => - api - .request(actors.owner, "DELETE", `/api/organizations/${foreign.id}`) - .pipe(Effect.orDie), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if (cloud) + yield* Effect.addFinalizer(() => + api + .request(actors.owner, "DELETE", `/api/organizations/${foreign.id}`) + .pipe(Effect.orDie), + ); + // On Cloud the member creates its own organization. On self-host only the root's owners + // create, so the owner creates it and invites the member as an administrator. const own = yield* body( Organization, - yield* api.request(actors.member, "POST", "/api/auth/organization/create", { - name: "Batch isolation own", - slug: `batch-own-${suffix}`, - keepCurrentActiveOrganization: true, - }), - ); - yield* Effect.addFinalizer(() => - api.request(actors.member, "DELETE", `/api/organizations/${own.id}`).pipe(Effect.orDie), + yield* api.request( + cloud ? actors.member : actors.owner, + "POST", + "/api/auth/organization/create", + { + name: "Batch isolation own", + slug: `batch-own-${suffix}`, + keepCurrentActiveOrganization: true, + }, + ), ); + if (cloud) + yield* Effect.addFinalizer(() => + api.request(actors.member, "DELETE", `/api/organizations/${own.id}`).pipe(Effect.orDie), + ); + else { + const invitation = yield* body( + Resource, + yield* api.request(actors.owner, "POST", "/api/auth/organization/invite-member", { + organizationId: own.id, + email: "member@example.test", + role: "admin", + }), + ); + expect( + (yield* api.request(actors.member, "POST", "/api/auth/organization/accept-invitation", { + invitationId: invitation.id, + })).status, + ).toBe(200); + } const mixed = [ read("organization", "access", { organization: home.id }), read("oauthClients", "list", { organization: home.id }), @@ -267,11 +291,15 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard batch isolation", (i const of = (index: number) => answers[order.indexOf(index)]; expect(mixed.map((_, index) => of(index)?.status)).toEqual(expected); const roles = yield* Effect.forEach([0, 4, 6, 8], (index) => roleOf(of(index))); - expect(roles).toEqual(["member", "owner", "member", "owner"]); + const own = cloud ? "owner" : "admin"; + expect(roles).toEqual(["member", own, "member", own]); } - // The owner's batch on the same organizations is answered with the owner's roles. + // The owner's batch on the same organizations is answered with the owner's roles. On + // self-host the owner created the member's organization, so it belongs to that one too. const owner = yield* send(actors.owner, mixed); - expect(statuses(owner)).toEqual([200, 200, 200, 200, 403, 403, 200, 200, 403, 200]); + expect(statuses(owner)).toEqual( + cloud ? [200, 200, 200, 200, 403, 403, 200, 200, 403, 200] : mixed.map(() => 200), + ); }), ), ); diff --git a/e2e/tests/groups.spec.ts b/e2e/tests/groups.spec.ts index a61e305b7c..056a1c8069 100644 --- a/e2e/tests/groups.spec.ts +++ b/e2e/tests/groups.spec.ts @@ -9,6 +9,7 @@ import { Evidence } from "../support/evidence.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { holdQuery, refreshVisiblePage } from "../support/query-transition.ts"; import { Organization } from "../support/contracts.ts"; +import { Target } from "../support/platform.ts"; import { scenarios } from "../test-plan.ts"; const Group = Schema.Struct({ @@ -458,12 +459,14 @@ layer(HostedLive, { excludeTestServices: true })("Organization groups", (it) => ); expect(orgResponse.status).toBe(200); const other = yield* body(Organization, orgResponse); - yield* Effect.addFinalizer(() => - api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( - Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), - Effect.orDie, - ), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if ((yield* Target).metadata.target === "cloud") + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.orDie, + ), + ); const prefix = `/api/organizations/${actors.organization.id}/groups`; const created = yield* api.request(actors.owner, "POST", prefix, { name: `Isolation ${randomUUID().slice(0, 8)}`, diff --git a/e2e/tests/pat-mcp.spec.ts b/e2e/tests/pat-mcp.spec.ts index cf4a3ee607..c8fb95de40 100644 --- a/e2e/tests/pat-mcp.spec.ts +++ b/e2e/tests/pat-mcp.spec.ts @@ -9,6 +9,7 @@ import { McpClient } from "../support/mcp-client.ts"; import { Evidence } from "../support/evidence.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { App, Organization } from "../support/contracts.ts"; +import { Target } from "../support/platform.ts"; import { appsManifest } from "../support/apps-release.ts"; const Token = Schema.Struct({ @@ -553,6 +554,7 @@ layer(HostedLive, { excludeTestServices: true })("PAT MCP", (it) => { const api = yield* Api, actors = yield* Actors, mcp = yield* McpClient; + const cloud = (yield* Target).metadata.target === "cloud"; // A temporary organization, so renaming it moves no other scenario's slug. const slug = `pat-rename-${randomUUID().slice(0, 8)}`, renamed = `${slug}-renamed`; @@ -569,7 +571,8 @@ layer(HostedLive, { excludeTestServices: true })("PAT MCP", (it) => { Effect.gen(function* () { for (const keyId of keys) yield* api.request(actors.owner, "POST", "/api/auth/api-key/delete", { keyId }); - yield* api.request(actors.owner, "DELETE", prefix); + // Self-host cannot remove organizations; the scenario's own server is discarded. + if (cloud) yield* api.request(actors.owner, "DELETE", prefix); }).pipe(Effect.orDie), ); const createdKey = yield* api.request(actors.owner, "POST", "/api/auth/api-key/create", { diff --git a/e2e/tests/resource-isolation.spec.ts b/e2e/tests/resource-isolation.spec.ts index 7e9ae3b6f5..025a6d85da 100644 --- a/e2e/tests/resource-isolation.spec.ts +++ b/e2e/tests/resource-isolation.spec.ts @@ -1,10 +1,11 @@ -/** Real Cloud organizations exercise tenant boundaries through the hosted API. */ +/** Real organizations on one host exercise tenant boundaries through the hosted API. */ import { expect, layer } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { randomUUID } from "node:crypto"; import { Api, body } from "../support/api.ts"; import { Actors } from "../support/actors.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; import { App, Resource, Organization } from "../support/contracts.ts"; import { scenarios } from "../test-plan.ts"; import { appsManifest } from "../support/apps-release.ts"; @@ -32,12 +33,14 @@ layer(HostedLive, { excludeTestServices: true })("Resource isolation", (it) => { keepCurrentActiveOrganization: true, }), ); - yield* Effect.addFinalizer(() => - api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( - Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), - Effect.orDie, - ), - ); + // Self-host cannot remove organizations; the scenario's own server is discarded instead. + if ((yield* Target).metadata.target === "cloud") + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `/api/organizations/${other.id}`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.orDie, + ), + ); const prefix = `/api/organizations/${actors.organization.id}`, foreign = `/api/organizations/${other.id}`; const app = yield* body( From 4439f03bcfbb144363bf1d7a186ac157b8067acf Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:43:28 -0800 Subject: [PATCH 4/8] Offer organization creation in the self-host switcher to root owners A signed-in user's GET /api/auth/self-host/capabilities answers whether they may create organizations, from the same root-owner check the create endpoint applies. The self-host switcher shows Create organization only when it says so; the server still refuses everyone else. Co-authored-by: Cursor --- .../src/implementation/root-organization.ts | 39 ++++-- .../self-host/web/src/contracts/auth.ts | 15 +++ .../routes/org.$organizationSlug.tsx | 9 +- e2e/test-plan.ts | 13 ++ .../self-host-organization-switcher.spec.ts | 123 ++++++++++++++++++ 5 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 e2e/tests/self-host-organization-switcher.spec.ts diff --git a/apps/hosted/self-host/src/implementation/root-organization.ts b/apps/hosted/self-host/src/implementation/root-organization.ts index 68f536cecf..0f3e320ea8 100644 --- a/apps/hosted/self-host/src/implementation/root-organization.ts +++ b/apps/hosted/self-host/src/implementation/root-organization.ts @@ -5,7 +5,7 @@ */ import type { AuthContext } from "@better-auth/core"; import type { BetterAuthPlugin } from "better-auth"; -import { APIError } from "better-auth/api"; +import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import { migrateProductSteps } from "@executor-js/hosted-server/migrations"; import { Cause, Effect, Exit, Option, Schema } from "effect"; import { SqlClient } from "effect/sql"; @@ -50,8 +50,19 @@ const ownsRoot = (adapter: AuthContext["adapter"], userId: string) => return Option.isSome(Schema.decodeUnknownOption(Membership)(member)); }); +const runOwnsRoot = (adapter: AuthContext["adapter"], userId: string) => + Effect.runPromiseExit(ownsRoot(adapter, userId)).then( + Exit.match({ + onSuccess: (owns) => owns, + onFailure: (cause) => { + throw Cause.squash(cause); + }, + }), + ); + /** - * The root record's table and the check organization creation asks. It must follow the + * The root record's table, the check organization creation asks, and the signed-in user's + * answer to it, which the dashboard reads to offer creation. It must follow the * organization plugin: Better Auth creates tables in plugin order. */ export const selfHostRoot = () => { @@ -62,6 +73,19 @@ export const selfHostRoot = () => { init: (context) => { adapter = context.adapter; }, + endpoints: { + selfHostCapabilities: createAuthEndpoint( + "/self-host/capabilities", + { method: "GET", use: [sessionMiddleware] }, + async (ctx) => + ctx.json({ + createOrganization: await runOwnsRoot( + ctx.context.adapter, + ctx.context.session.user.id, + ), + }), + ), + }, schema: { [rootModel]: { fields: { @@ -77,16 +101,7 @@ export const selfHostRoot = () => { }, } 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); - }, - }), - ), + adapter === undefined ? Promise.resolve(false) : runOwnsRoot(adapter, userId), }; }; diff --git a/apps/hosted/self-host/web/src/contracts/auth.ts b/apps/hosted/self-host/web/src/contracts/auth.ts index 8946e5ff92..c2f213af97 100644 --- a/apps/hosted/self-host/web/src/contracts/auth.ts +++ b/apps/hosted/self-host/web/src/contracts/auth.ts @@ -28,6 +28,21 @@ const liveConfiguration = browserOnly( ), ); +/** + * Whether the signed-in user may create organizations: only owners of the root organization may. + * It offers the action only; the server checks again when one is created. + */ +export const organizationCreationAtom = browserOnly( + BrowserAtoms.atom( + authRequest((options) => authClient.$fetch("/self-host/capabilities", options)).pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Struct({ createOrganization: Schema.Boolean })), + ), + Effect.map((capabilities) => capabilities.createOrganization), + ), + ), +); + /** The settings the server read for this sign-in page, sent to the browser with it. */ const entryConfiguration = Atom.make>(Option.none()).pipe( Atom.serializable({ key: "self-host:sign-in-settings", schema: Schema.Option(SignInSettings) }), diff --git a/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx b/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx index 4e5fbde917..1b354d6094 100644 --- a/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx +++ b/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx @@ -1,10 +1,13 @@ import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; import { DashboardEntryPending } from "@executor-js/hosted-web/entry"; import { PagePending } from "@executor-js/hosted-web/page-pending"; import { DashboardShell } from "@executor-js/hosted-web/shell"; import { OrganizationBoundary, OrganizationContent } from "@executor-js/hosted-web/organization"; import { NameAccountDialog } from "@executor-js/hosted-web/pages/name-account-dialog"; import { Navigation } from "../navigation.tsx"; +import { organizationCreationAtom } from "../../contracts/auth.ts"; /** The URL owns this tab's organization; all product pages inherit this boundary. */ export const Route = createFileRoute("/org/$organizationSlug")({ @@ -14,9 +17,13 @@ export const Route = createFileRoute("/org/$organizationSlug")({ }); function OrganizationLayout() { const { organizationSlug } = Route.useParams(); + const creation = useAtomValue(organizationCreationAtom); return ( - }> + } + > }> diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index d4bb7674cf..396ba1f38f 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -227,6 +227,19 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostOrganizationSwitcher: { + fixtures: "actors", + file: "self-host-organization-switcher.spec.ts", + title: + "the self-host switcher offers creation only to root owners, and switching shows each organization its own apps", + targets: { + "self-host": scheduled, + cloud: na( + "Cloud offers creation to every signed-in account; its switcher has its own scenarios.", + ), + local: na("Local has no hosted organizations."), + }, + }, selfHostOrganizationCreationBootstrap: { legacyStorage: true, file: "self-host-organization-creation.spec.ts", diff --git a/e2e/tests/self-host-organization-switcher.spec.ts b/e2e/tests/self-host-organization-switcher.spec.ts new file mode 100644 index 0000000000..79d8b98456 --- /dev/null +++ b/e2e/tests/self-host-organization-switcher.spec.ts @@ -0,0 +1,123 @@ +/** + * The self-host organization switcher offers creation to owners of the root organization only, + * as the server decides, and switching shows each organization its own apps. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { Actors } from "../support/actors.ts"; +import { Api } from "../support/api.ts"; +import { appsManifest } from "../support/apps-release.ts"; +import { Browser } from "../support/browser.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; +import { targetHosts } from "../support/role-hosts.ts"; +import { scenarios } from "../test-plan.ts"; + +const rootOnly = `import { defineApp, object, query, router } from "apps"; +export default defineApp({}, async () => ({ tools: router({ + ping: query({ input: object({}) }, async () => "pong"), +}) }));`; + +/** Open an organization's apps once the server has answered whether the user may create. */ +const openApps = (page: Page, origin: string, slug: string) => + Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/self-host/capabilities")), + page.goto(`${origin}/org/${slug}/apps`), + ]).then(([response]) => response.json()); + +const switcher = (page: Page) => page.getByRole("button", { name: /^Organization: / }); + +layer(HostedLive, { excludeTestServices: true })("Self-host organization switcher", (it) => { + it.effect(scenarios.selfHostOrganizationSwitcher.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + browser = yield* Browser; + const root = actors.organization; + const origin = targetHosts(yield* Target).browser; + expect( + (yield* api.request(actors.owner, "POST", `/api/organizations/${root.id}/apps/deploy`, { + name: "Root only app", + files: [{ path: "index.ts", content: rootOnly }, appsManifest], + })).status, + ).toBe(200); + + yield* browser.login(actors.owner); + expect( + yield* browser.use("The owner opens the root's apps", (page) => + openApps(page, origin, root.slug), + ), + ).toEqual({ createOrganization: true }); + yield* browser.use("The root lists its app", (page) => + page.getByText("Root only app", { exact: true }).first().waitFor(), + ); + yield* browser.use("Open the organization switcher", (page) => switcher(page).click()); + yield* browser.use("Choose Create organization", (page) => + page.getByRole("menuitem", { name: "Create organization", exact: true }).click(), + ); + yield* browser.use("Name the organization", (page) => + page.getByRole("dialog").getByLabel("Name", { exact: true }).fill("Second team"), + ); + yield* browser.checkpoint("The root owner creates an organization from the switcher"); + yield* browser.use("Create it", (page) => + page + .getByRole("dialog") + .getByRole("button", { name: "Create organization", exact: true }) + .click(), + ); + yield* browser.use("The new organization opens", (page) => + page + .waitForURL("**/org/second-team/apps") + .then(() => + page + .getByRole("button", { name: "Organization: Second team", exact: true }) + .waitFor(), + ) + .then(() => page.getByRole("button", { name: "Add app", exact: true }).waitFor()), + ); + expect( + yield* browser.use("The new organization lists no app of the root", (page) => + page.getByText("Root only app", { exact: true }).count(), + ), + ).toBe(0); + yield* browser.checkpoint("The new organization opens without the root's apps"); + + yield* browser.use("Open the organization switcher", (page) => switcher(page).click()); + yield* browser.use("Switch back to the root", (page) => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).click(), + ); + yield* browser.use("The root opens with its app", (page) => + page + .waitForURL(`**/org/${root.slug}/apps`) + .then(() => page.getByText("Root only app", { exact: true }).first().waitFor()), + ); + + yield* browser.login(actors.admin); + expect( + yield* browser.use("The admin opens the root's apps", (page) => + openApps(page, origin, root.slug), + ), + ).toEqual({ createOrganization: false }); + yield* browser.use("Open the admin's organization switcher", (page) => + switcher(page) + .click() + .then(() => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).waitFor(), + ), + ); + expect( + yield* browser.use("The admin's switcher", (page) => + Promise.all([ + page.getByRole("menuitemradio").count(), + page.getByRole("menuitem", { name: "Create organization", exact: true }).count(), + ]), + ), + ).toEqual([1, 0]); + yield* browser.checkpoint("An admin of the root is not offered creation"); + }), + ), + ); +}); From 689f8bdd9695a038125d4af8ba2e5ec91fccf947 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:07:01 -0800 Subject: [PATCH 5/8] Prove self-host setup races, setup rollback, forged creators and the switcher's unknown answer Five callers racing to set up a fresh instance leave one owner, organization and root record. A setup that fails at its last write leaves no rows and a retry succeeds. Creation ignores a body naming the owner and refuses the owner's own API key and MCP OAuth token, each shown valid where it was issued. The switcher offers no creation while the capability answer is pending or failed, and recovers on reload. Co-authored-by: Cursor --- e2e/test-plan.ts | 46 ++++++ .../self-host-organization-creation.spec.ts | 92 ++++++++++++ .../self-host-organization-switcher.spec.ts | 79 ++++++++++ e2e/tests/self-host-setup-integrity.spec.ts | 138 ++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 e2e/tests/self-host-setup-integrity.spec.ts diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 396ba1f38f..5ede864484 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -227,6 +227,39 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostSetupRace: { + legacyStorage: true, + file: "self-host-setup-integrity.spec.ts", + title: + "five callers racing to set up a fresh self-host leave one owner, one organization and one root record", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no instance setup."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostSetupRollback: { + legacyStorage: true, + file: "self-host-setup-integrity.spec.ts", + title: + "a self-host setup that fails partway leaves no account, organization or root record, and a retry succeeds", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no instance setup."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostOrganizationCreationForgedCallers: { + fixtures: "actors", + file: "self-host-organization-creation.spec.ts", + title: + "self-host organization creation ignores a body naming the owner and refuses the owner's own API key and OAuth token", + 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."), + }, + }, selfHostOrganizationSwitcher: { fixtures: "actors", file: "self-host-organization-switcher.spec.ts", @@ -240,6 +273,19 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostOrganizationSwitcherCapabilityUnavailable: { + fixtures: "actors", + file: "self-host-organization-switcher.spec.ts", + title: + "the self-host switcher offers no creation while the capability answer is pending or failed, and recovers on reload", + targets: { + "self-host": scheduled, + cloud: na( + "Cloud offers creation to every signed-in account; its switcher has its own scenarios.", + ), + local: na("Local has no hosted organizations."), + }, + }, selfHostOrganizationCreationBootstrap: { legacyStorage: true, file: "self-host-organization-creation.spec.ts", diff --git a/e2e/tests/self-host-organization-creation.spec.ts b/e2e/tests/self-host-organization-creation.spec.ts index a93da3626a..66e4847952 100644 --- a/e2e/tests/self-host-organization-creation.spec.ts +++ b/e2e/tests/self-host-organization-creation.spec.ts @@ -11,6 +11,10 @@ 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 { McpClient } from "../support/mcp-client.ts"; +import { consentTo } from "../support/mcp-connections.ts"; +import { Target } from "../support/platform.ts"; +import { targetHosts } from "../support/role-hosts.ts"; import { serverControl } from "../support/server-control.ts"; import { scenarios } from "../test-plan.ts"; @@ -22,6 +26,8 @@ const Members = Schema.Struct({ ), }); const Configuration = Schema.Struct({ setup: Schema.Boolean }); +const SessionUser = Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }); +const Capabilities = Schema.Struct({ createOrganization: Schema.Boolean }); const create = (session: Session, name: string, headers?: Record) => Effect.flatMap(Api, (api) => @@ -141,6 +147,92 @@ layer(HostedLive, { excludeTestServices: true })("Self-host organization creatio ), ); + it.effect(scenarios.selfHostOrganizationCreationForgedCallers.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + target = yield* Target; + const root = actors.organization.id; + const anonymous = yield* api.session(); + const owner = yield* body( + SessionUser, + yield* api.request(actors.owner, "GET", "/api/auth/get-session"), + ); + const capabilities = (session: Session, headers?: Record) => + api.request(session, "GET", "/api/auth/self-host/capabilities", undefined, headers); + const answer = (session: Session) => + capabilities(session).pipe( + Effect.flatMap((response) => body(Capabilities, response)), + Effect.map((value) => value.createOrganization), + ); + expect(yield* answer(actors.owner)).toBe(true); + expect(yield* answer(actors.admin)).toBe(false); + expect(yield* answer(actors.member)).toBe(false); + expect((yield* capabilities(anonymous)).status).toBe(401); + + // Better Auth takes the creator from the session; a body naming the owner changes nothing. + const spoof = (session: Session) => + api.request(session, "POST", "/api/auth/organization/create", { + name: "Spoofed team", + slug: `spoof-${randomUUID().slice(0, 8)}`, + keepCurrentActiveOrganization: true, + userId: owner.user.id, + }); + expect((yield* spoof(actors.admin)).status).toBe(403); + expect((yield* spoof(actors.member)).status).toBe(403); + expect((yield* spoof(anonymous)).status).toBe(401); + + const key = yield* body( + Key, + yield* api.request(actors.owner, "POST", "/api/auth/api-key/create", { + name: "Owner key", + }), + ); + const consent = yield* consentTo(actors.owner, `${targetHosts(target).mcp}/mcp`, { + "x-executor-organization": root, + }); + expect(consent.status, JSON.stringify(consent)).toBe(200); + const oauth = yield* consent.tokens; + // Each credential is the owner's and valid where it was issued: the API key at the + // organization API, the OAuth token, bound to the MCP resource, through an MCP client. + expect( + (yield* api.request(anonymous, "GET", `/api/organizations/${root}/access`, undefined, { + authorization: `Bearer ${Redacted.value(key.key)}`, + })).status, + ).toBe(200); + yield* (yield* McpClient).connect(oauth, "creation-refusal", { organization: root }); + for (const [label, token] of [ + ["API key", Redacted.value(key.key)], + ["OAuth access token", Redacted.value(oauth)], + ] as const) { + const bearer = { authorization: `Bearer ${token}`, "x-executor-organization": root }; + expect((yield* create(anonymous, `${label} team`, bearer)).status, label).toBe(401); + expect((yield* capabilities(anonymous, bearer)).status, label).toBe(401); + expect( + (yield* api.request( + anonymous, + "POST", + "/api/auth/organization/create", + { + name: `${label} spoof`, + slug: `bearer-${randomUUID().slice(0, 8)}`, + keepCurrentActiveOrganization: true, + userId: owner.user.id, + }, + bearer, + )).status, + `${label} naming the owner`, + ).toBe(401); + } + expect((yield* listed(actors.owner)).map((organization) => organization.id)).toEqual([ + root, + ]); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); + it.effect(scenarios.selfHostOrganizationCreationBootstrap.title, (context) => withCase( context, diff --git a/e2e/tests/self-host-organization-switcher.spec.ts b/e2e/tests/self-host-organization-switcher.spec.ts index 79d8b98456..075ce11c8b 100644 --- a/e2e/tests/self-host-organization-switcher.spec.ts +++ b/e2e/tests/self-host-organization-switcher.spec.ts @@ -120,4 +120,83 @@ layer(HostedLive, { excludeTestServices: true })("Self-host organization switche }), ), ); + it.effect(scenarios.selfHostOrganizationSwitcherCapabilityUnavailable.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors, + browser = yield* Browser; + const root = actors.organization; + const origin = targetHosts(yield* Target).browser; + let arrived = () => {}; + const arrival = new Promise((resolve) => { + arrived = resolve; + }); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const creation = (page: Page) => + page.getByRole("menuitem", { name: "Create organization", exact: true }).count(); + const openSwitcher = (page: Page) => + switcher(page) + .click() + .then(() => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).waitFor(), + ); + + yield* browser.login(actors.owner); + yield* browser.use("Hold the capability answer", (page) => + page.route("**/api/auth/self-host/capabilities", (route) => { + arrived(); + return held.then(() => + route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ message: "Authentication is unavailable. Try again." }), + }), + ); + }), + ); + yield* browser.use("The owner opens the root's apps", (page) => + page.goto(`${origin}/org/${root.slug}/apps`).then(() => arrival), + ); + expect( + yield* browser.use("While the answer is pending, no create action", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(0); + yield* browser.checkpoint("No create action while the capability is pending"); + yield* browser.use("Close the switcher", (page) => page.keyboard.press("Escape")); + + yield* browser.use("The capability answer fails", (page) => + Promise.all([ + page.waitForResponse((response) => + response.url().endsWith("/api/auth/self-host/capabilities"), + ), + Promise.resolve().then(release), + ]), + ); + expect( + yield* browser.use("After a failed answer, no create action", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(0); + yield* browser.checkpoint("No create action after the capability fails"); + yield* browser.use("Close the switcher", (page) => page.keyboard.press("Escape")); + + yield* browser.use("The server answers again", (page) => + page.unroute("**/api/auth/self-host/capabilities"), + ); + expect( + yield* browser.use("Reload the root's apps", (page) => openApps(page, origin, root.slug)), + ).toEqual({ createOrganization: true }); + expect( + yield* browser.use("Once the server answers, the owner may create", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(1); + }), + ), + ); }); diff --git a/e2e/tests/self-host-setup-integrity.spec.ts b/e2e/tests/self-host-setup-integrity.spec.ts new file mode 100644 index 0000000000..db400970e8 --- /dev/null +++ b/e2e/tests/self-host-setup-integrity.spec.ts @@ -0,0 +1,138 @@ +/** + * First setup creates the instance's only owner, organization and root record together. Callers + * racing to set up a fresh instance leave exactly one of each, and a setup that fails partway + * leaves nothing behind, so a retry starts from an empty instance. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { password } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withCase } from "../support/case.ts"; +import { Organization } 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 Configuration = Schema.Struct({ setup: Schema.Boolean }); + +const countQueries = [ + { + sql: `select + (select count(*)::int from "user") as users, + (select count(*)::int from "account") as accounts, + (select count(*)::int from "organization") as organizations, + (select count(*)::int from "member") as members, + (select count(*)::int from "session") as sessions`, + }, + { sql: `select "id", "organizationId" from "selfHostRoot"` }, +] as const; + +const setup = (session: Session, index: number) => + Effect.flatMap(Api, (api) => + api.request(session, "POST", "/api/auth/self-host/setup", { + name: `Racer ${index}`, + email: `racer-${index}@example.test`, + password, + organizationName: `Race team ${index}`, + }), + ); + +const configuration = (session: Session) => + Effect.flatMap(Api, (api) => + api + .request(session, "GET", "/api/auth/self-host/config") + .pipe(Effect.flatMap((response) => body(Configuration, response))), + ); + +layer(HostedLive, { excludeTestServices: true })("Self-host setup integrity", (it) => { + it.effect(scenarios.selfHostSetupRace.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api; + const sessions = yield* Effect.forEach([0, 1, 2, 3, 4], () => api.session()); + expect((yield* configuration(sessions[0]!)).setup).toBe(true); + const statuses = yield* Effect.forEach( + sessions, + (session, index) => setup(session, index).pipe(Effect.map((response) => response.status)), + { concurrency: "unbounded" }, + ); + expect(statuses.toSorted()).toEqual([200, 403, 403, 403, 403]); + const winner = statuses.indexOf(200); + const organizations = yield* body( + Schema.Array(Organization), + yield* api.request(sessions[winner]!, "GET", "/api/auth/organization/list"), + ); + expect(organizations).toHaveLength(1); + expect((yield* configuration(sessions[0]!)).setup).toBe(false); + + const [[counts] = [], root = []] = yield* legacyStorage(countQueries); + expect(counts).toEqual({ + users: 1, + accounts: 1, + organizations: 1, + members: 1, + sessions: 1, + }); + expect(root).toEqual([{ id: "root", organizationId: organizations[0]!.id }]); + }), + ), + ); + + it.effect(scenarios.selfHostSetupRollback.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api; + // The membership is the last row setup writes, after the user, account, organization + // and root record. + yield* legacyStorage([ + { + sql: `create function e2e_refuse_member() returns trigger language plpgsql as $$ + begin raise exception 'synthetic setup failure'; end $$`, + }, + { + sql: `create trigger e2e_refuse_member before insert on "member" + for each row execute function e2e_refuse_member()`, + }, + ]); + yield* serverControl("start"); + const failed = yield* api.session(); + const first = yield* setup(failed, 1); + expect(first.status).toBeGreaterThanOrEqual(500); + expect((yield* configuration(failed)).setup).toBe(true); + const [[afterFailure] = [], rootAfterFailure = []] = yield* legacyStorage(countQueries); + expect(afterFailure).toEqual({ + users: 0, + accounts: 0, + organizations: 0, + members: 0, + sessions: 0, + }); + expect(rootAfterFailure).toEqual([]); + + yield* legacyStorage([ + { sql: `drop trigger e2e_refuse_member on "member"` }, + { sql: "drop function e2e_refuse_member()" }, + ]); + yield* serverControl("start"); + const retried = yield* api.session(); + expect((yield* setup(retried, 2)).status).toBe(200); + const organizations = yield* body( + Schema.Array(Organization), + yield* api.request(retried, "GET", "/api/auth/organization/list"), + ); + expect(organizations).toHaveLength(1); + const [[afterRetry] = [], rootAfterRetry = []] = yield* legacyStorage(countQueries); + expect(afterRetry).toEqual({ + users: 1, + accounts: 1, + organizations: 1, + members: 1, + sessions: 1, + }); + expect(rootAfterRetry).toEqual([{ id: "root", organizationId: organizations[0]!.id }]); + }), + ), + ); +}); From 75565d7420d64ad35d3bb92cec2b51cf451c7be5 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:26:17 -0800 Subject: [PATCH 6/8] Prove SSO admission against the self-host image The released-image suite gains two scenarios that run the native image with SSO configured against the synthetic OpenID provider. A sidecar owns the container network and forwards the provider's HTTPS address, so the image trusts it through NODE_EXTRA_CA_CERTS. A verified, approved address without a membership, an unverified address and an unapproved domain are refused with their codes and no session or membership. An invited member who connected SSO signs in, and is refused once removed. Co-authored-by: Cursor --- e2e/docker-release.config.ts | 1 + .../docker-release-sso-admission.spec.ts | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 e2e/tests/docker-release-sso-admission.spec.ts diff --git a/e2e/docker-release.config.ts b/e2e/docker-release.config.ts index d0303c8b6f..7cb3e89079 100644 --- a/e2e/docker-release.config.ts +++ b/e2e/docker-release.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "e2e/tests/docker-release-watchdog.spec.ts", "e2e/tests/docker-release-app-isolation.spec.ts", "e2e/tests/docker-oauth-renewal.spec.ts", + "e2e/tests/docker-release-sso-admission.spec.ts", "e2e/tests/workflow-engine.spec.ts", ], testTimeout: 180_000, diff --git a/e2e/tests/docker-release-sso-admission.spec.ts b/e2e/tests/docker-release-sso-admission.spec.ts new file mode 100644 index 0000000000..a6a0f93cf2 --- /dev/null +++ b/e2e/tests/docker-release-sso-admission.spec.ts @@ -0,0 +1,332 @@ +/** + * The self-host image admits only invited members through SSO. The synthetic OpenID provider runs + * on the runner. A sidecar owns the container network: it publishes the product's port and + * forwards the provider's HTTPS address, so the image reads discovery from the same + * `https://127.0.0.1` issuer and trusts its certificate through `NODE_EXTRA_CA_CERTS`, without + * disabling TLS checks. The sign-in flow runs over HTTP exactly as a browser follows it. + */ +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Console, Effect, Exit, Schedule, Schema } from "effect"; +import { FetchHttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { driver } from "../support/platform.ts"; +import { selfHostSsoIdp, type IdentityMode } from "../support/sso.ts"; + +const password = "Synthetic-release-password-123!"; +const forwarder = `const net = require("node:net"); +const [port, host] = [Number(process.argv[1]), process.argv[2]]; +net.createServer((client) => { + const upstream = net.connect(port, host); + client.pipe(upstream).pipe(client); + client.on("error", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); +}).listen(port, "127.0.0.1");`; + +const freePort = driver( + "allocate port", + () => + new Promise((resolve, reject) => { + const listener = createServer(); + listener.once("error", reject); + listener.listen(0, "127.0.0.1", () => { + const address = listener.address(); + listener.close(() => + address === null || typeof address === "string" + ? reject(new Error("No test port")) + : resolve(address.port), + ); + }); + }), +); + +/** A cookie jar for one person's requests, following redirects by hand as a browser does. */ +const client = (origin: string) => { + const cookies = new Map(); + const send = (url: string, init: RequestInit = {}) => + driver(`${init.method ?? "GET"} ${new URL(url).pathname}`, (signal) => + fetch(url, { + ...init, + signal, + redirect: "manual", + headers: { + ...(new URL(url).origin === origin ? { origin } : {}), + ...(init.body === undefined ? {} : { "content-type": "application/json" }), + ...(cookies.size === 0 || new URL(url).origin !== origin + ? {} + : { cookie: [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") }), + }, + }), + ).pipe( + Effect.tap((response) => + Effect.sync(() => { + if (new URL(url).origin !== origin) return; + for (const header of response.headers.getSetCookie()) { + const [pair = ""] = header.split(";"); + const at = pair.indexOf("="); + const [name, value] = [pair.slice(0, at), pair.slice(at + 1)]; + if (value === "" || /max-age=0/i.test(header)) cookies.delete(name); + else cookies.set(name, value); + } + }), + ), + ); + const json = (path: string, data?: unknown) => + send( + `${origin}${path}`, + data === undefined ? {} : { method: "POST", body: JSON.stringify(data) }, + ); + const read = (path: string) => + json(path).pipe(Effect.flatMap((response) => driver(`read ${path}`, () => response.json()))); + return { send, json, read, cookies }; +}; + +/** + * Start the provider, the sidecar and the image under test with SSO configured, and set up the + * owner. Everything is removed when the scope closes. + */ +const ssoImage = Effect.gen(function* () { + const processes = yield* ChildProcessSpawner.ChildProcessSpawner; + const image = yield* Config.String("EXECUTOR_E2E_DOCKER_IMAGE"); + const docker = (args: readonly string[]) => + processes.string(ChildProcess.make("docker", args, { extendEnv: true })); + const port = yield* freePort; + const origin = `http://127.0.0.1:${port}`; + const idp = yield* selfHostSsoIdp(origin); + const issuerPort = new URL(idp.issuer).port; + const name = `executor-sso-${randomBytes(6).toString("hex")}`; + yield* Effect.acquireRelease( + docker([ + "run", + "--detach", + "--name", + `${name}-network`, + "--add-host", + "sso-idp.e2e.internal:host-gateway", + "--publish", + `127.0.0.1:${port}:8080`, + "node:24.22.1-bookworm-slim", + "node", + "-e", + forwarder, + issuerPort, + "sso-idp.e2e.internal", + ]), + () => docker(["rm", "--force", `${name}-network`]).pipe(Effect.orDie), + ); + const environment = { + DO_NOT_TRACK: "1", + PORT: "8080", + BETTER_AUTH_URL: origin, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + EXECUTOR_ENCRYPTION_KEY: randomBytes(32).toString("hex"), + SSO_DISCOVERY_URL: idp.discoveryUrl, + SSO_CLIENT_ID: "synthetic-sso-client", + SSO_CLIENT_SECRET: "synthetic-sso-secret", + SSO_ALLOWED_DOMAINS: "sso.example.test", + NODE_EXTRA_CA_CERTS: "/run/sso-ca.pem", + }; + yield* Effect.acquireRelease( + processes.string( + ChildProcess.make( + "docker", + [ + "run", + "--detach", + "--name", + name, + "--init", + "--network", + `container:${name}-network`, + "--volume", + `${idp.certificate}:/run/sso-ca.pem:ro`, + ...Object.keys(environment).flatMap((variable) => ["--env", variable]), + image, + ], + { env: environment, extendEnv: true }, + ), + ), + () => docker(["rm", "--force", "--volumes", name]).pipe(Effect.orDie), + ); + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? docker(["logs", name]).pipe(Effect.flatMap(Console.error), Effect.ignore) + : Effect.void, + ); + const owner = client(origin); + yield* owner.json("/health").pipe( + Effect.flatMap((response) => + response.status === 200 ? Effect.void : Effect.fail("not ready"), + ), + Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 240 }), + ); + const imageId = (yield* docker(["inspect", "--format", "{{.Image}}", name])).trim(); + const build = (yield* owner.json("/")).headers.get("executor-build"); + expect( + (yield* owner.json("/api/auth/self-host/setup", { + name: "SSO Owner", + email: "owner@sso.example.test", + password, + organizationName: "SSO root", + })).status, + ).toBe(200); + const [organization] = yield* owner + .read("/api/auth/organization/list") + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.String }))), + ), + ); + if (organization === undefined) return yield* Effect.die("Setup created no organization"); + /** + * Follow one OAuth start (`/sign-in/social` or `/link-social`, as the dashboard calls them) through the provider and back to + * the product's callback, and return where the callback sends the browser. + */ + const ssoFlow = ( + person: ReturnType, + email: string, + mode: IdentityMode, + start: "/api/auth/sign-in/social" | "/api/auth/link-social", + ) => + Effect.gen(function* () { + yield* idp.configure(email, mode); + const started = yield* person + .json( + start, + start === "/api/auth/sign-in/social" + ? { provider: "sso", callbackURL: "/", errorCallbackURL: "/login" } + : { + provider: "sso", + callbackURL: "/account/security", + errorCallbackURL: "/account/security", + }, + ) + .pipe( + Effect.flatMap((response) => + driver("read OAuth start", () => response.text()).pipe( + Effect.map((text) => ({ status: response.status, text })), + ), + ), + ); + const { url } = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ url: Schema.String })), + )(started.text).pipe( + Effect.mapError( + () => new Error(`${start} answered ${started.status}: ${started.text.slice(0, 300)}`), + ), + ); + // The browser cannot trust the synthetic certificate; it reaches the same sign-in page on + // the provider's loopback HTTP listener, as the browser scenarios route it. + const authorize = new URL(url); + const provider = yield* person.send(`${idp.origin}${authorize.pathname}${authorize.search}`); + const callback = provider.headers.get("location"); + if (callback === null) return yield* Effect.die("The provider did not redirect back"); + const answered = yield* person.send(callback); + return new URL(answered.headers.get("location") ?? "/", origin); + }); + const session = (person: ReturnType) => + person + .read("/api/auth/get-session") + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect( + Schema.NullOr(Schema.Struct({ user: Schema.Struct({ email: Schema.String }) })), + ), + ), + ); + return { origin, owner, organization, ssoFlow, session, imageId, build }; +}); + +const signIn = ( + sso: Effect.Success, + person: ReturnType, + email: string, + mode: IdentityMode, +) => sso.ssoFlow(person, email, mode, "/api/auth/sign-in/social"); + +it.live("the self-host image refuses SSO for anyone without a membership and says why", () => + Effect.scoped( + Effect.gen(function* () { + const sso = yield* ssoImage; + yield* Console.log(`image ${sso.imageId} build ${sso.build}`); + const expectRefused = (email: string, mode: IdentityMode, code: string) => + Effect.gen(function* () { + const person = client(sso.origin); + const landed = yield* signIn(sso, person, email, mode); + expect(landed.pathname, email).toBe("/login"); + expect(landed.searchParams.get("error"), email).toBe(code); + expect(yield* sso.session(person), email).toBeNull(); + }); + // Verified, from the approved domain, never invited: SSO adds no one. + yield* expectRefused("stranger@sso.example.test", "valid", "sso_not_member"); + yield* expectRefused("unverified@sso.example.test", "unverified", "sso_email_unverified"); + yield* expectRefused("member@outside.example.test", "valid", "sso_domain_not_allowed"); + const members = yield* sso.owner.read( + `/api/auth/organization/list-members?organizationId=${sso.organization.id}`, + ); + expect(members).toMatchObject({ total: 1, members: [{ role: "owner" }] }); + }), + ).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer])), +); + +it.live( + "the self-host image signs in an invited member through SSO and refuses them once removed", + () => + Effect.scoped( + Effect.gen(function* () { + const sso = yield* ssoImage; + yield* Console.log(`image ${sso.imageId} build ${sso.build}`); + const invited = yield* sso.owner + .json("/api/auth/organization/invite-member", { + organizationId: sso.organization.id, + email: "member@sso.example.test", + role: "member", + }) + .pipe( + Effect.flatMap((response) => driver("read invitation", () => response.json())), + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.String }))), + ); + const member = client(sso.origin); + expect( + (yield* member.json("/api/auth/self-host/register", { + invitation: invited.id, + email: "member@sso.example.test", + name: "SSO Member", + password, + })).status, + ).toBe(200); + expect(yield* member.read("/api/auth/organization/list")).toMatchObject([ + { id: sso.organization.id }, + ]); + // The registered member connects SSO from Security; the link is not a sign-in. + const linked = yield* sso.ssoFlow( + member, + "member@sso.example.test", + "valid", + "/api/auth/link-social", + ); + expect(linked.searchParams.get("error")).toBeNull(); + + const returning = client(sso.origin); + const admitted = yield* signIn(sso, returning, "member@sso.example.test", "valid"); + expect(admitted.searchParams.get("error")).toBeNull(); + expect(yield* sso.session(returning)).toMatchObject({ + user: { email: "member@sso.example.test" }, + }); + + expect( + (yield* sso.owner.json("/api/auth/organization/remove-member", { + organizationId: sso.organization.id, + memberIdOrEmail: "member@sso.example.test", + })).status, + ).toBe(200); + const removed = client(sso.origin); + const refused = yield* signIn(sso, removed, "member@sso.example.test", "valid"); + expect(refused.pathname).toBe("/login"); + expect(refused.searchParams.get("error")).toBe("sso_not_member"); + expect(yield* sso.session(removed)).toBeNull(); + }), + ).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer])), +); From 4b144f658081d0639967182b4fa2c905ae4d2119 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:46:55 -0800 Subject: [PATCH 7/8] Add a fail-closed offline gate for the self-host root record scripts/inspect-self-host-root.ts loads an executor-host export archive into an in-memory PGlite and compares it with the identity the operator recorded before the upgrade: one organization ID and its full set of owners. It resolves PGlite from the repository, refuses any version but the product's 0.5.8 before running it, and requires Postgres 18. It passes only when the archive has exactly one root record, id root, naming that organization, the organization's owners are exactly the recorded set, and the journal records 1 root_organization. It exits 1 when the gate fails, 2 for an invalid invocation or baseline and 3 when the archive or PGlite cannot be used, and prints a fixed report without rows, SQL errors or stack traces. e2e:root-inspection runs it as a separate process over synthetic PGlite archives for each pass, failure, baseline and error case. Co-authored-by: Cursor --- e2e/ci-selection.ts | 1 + e2e/self-host-root-inspection.config.ts | 6 + e2e/tests/self-host-root-inspection.spec.ts | 469 ++++++++++++++++++++ package.json | 1 + scripts/inspect-self-host-root.ts | 210 +++++++++ 5 files changed, 687 insertions(+) create mode 100644 e2e/self-host-root-inspection.config.ts create mode 100644 e2e/tests/self-host-root-inspection.spec.ts create mode 100644 scripts/inspect-self-host-root.ts diff --git a/e2e/ci-selection.ts b/e2e/ci-selection.ts index 85d7bbd01f..2f200339d1 100644 --- a/e2e/ci-selection.ts +++ b/e2e/ci-selection.ts @@ -591,6 +591,7 @@ const suiteConfigs: Record Promise> = { "export-public.config.ts": () => import("./export-public.config.ts"), "docker-release.config.ts": () => import("./docker-release.config.ts"), "self-host-native.config.ts": () => import("./self-host-native.config.ts"), + "self-host-root-inspection.config.ts": () => import("./self-host-root-inspection.config.ts"), "installer.config.ts": () => import("./installer.config.ts"), "local-bootstrap.config.ts": () => import("./local-bootstrap.config.ts"), "npm-launcher.config.ts": () => import("./npm-launcher.config.ts"), diff --git a/e2e/self-host-root-inspection.config.ts b/e2e/self-host-root-inspection.config.ts new file mode 100644 index 0000000000..409fa31205 --- /dev/null +++ b/e2e/self-host-root-inspection.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; + +/** The offline self-host root gate, run as a separate process over synthetic PGlite archives. */ +export default defineConfig({ + test: { include: ["e2e/tests/self-host-root-inspection.spec.ts"], testTimeout: 60_000 }, +}); diff --git a/e2e/tests/self-host-root-inspection.spec.ts b/e2e/tests/self-host-root-inspection.spec.ts new file mode 100644 index 0000000000..577baf338c --- /dev/null +++ b/e2e/tests/self-host-root-inspection.spec.ts @@ -0,0 +1,469 @@ +/** + * The offline root-record gate (`scripts/inspect-self-host-root.ts`) run as an operator runs it: + * a separate Node process over a PGlite data-directory archive and a recorded baseline. The + * archives are real PGlite dumps with the product's table and column names. Every case checks + * the exit code, the fixed report, and that neither stream carries rows, SQL errors, stack + * traces or the session token planted in each archive. + */ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +const root = "org-root-1"; +const owners = ["user-owner-a", "user-owner-b"]; +const canary = "CANARY-SESSION-TOKEN-7d1f"; + +const schema = [ + `create table "organization" ("id" text primary key, "name" text not null, "slug" text not null)`, + `create table "member" ("id" text primary key, "organizationId" text not null, "userId" text not null, "role" text not null)`, + `create table "session" ("id" text primary key, "token" text not null)`, + `insert into "session" values ('session-1', '${canary}')`, +]; +const rootTable = `create table "selfHostRoot" ("id" text not null, "organizationId" text not null)`; +const journalTable = `create table private_self_host_migrations (migration_id integer primary key, created_at timestamptz not null default now(), name text not null)`; +const organizations = [ + `insert into "organization" values ('${root}', 'Root', 'executor'), ('org-second', 'Second', 'second')`, + `insert into "member" values + ('m1', '${root}', 'user-owner-a', 'owner'), + ('m2', '${root}', 'user-owner-b', 'owner'), + ('m3', '${root}', 'user-admin', 'admin'), + ('m4', 'org-second', 'user-outsider', 'owner')`, +]; +const recorded = [`insert into "selfHostRoot" values ('root', '${root}')`]; +const journal = [ + `insert into private_self_host_migrations (migration_id, name) values (1, 'root_organization')`, +]; +/** An upgraded install whose root has two legitimate owners. */ +const valid = [...schema, rootTable, journalTable, ...organizations, ...recorded, ...journal]; + +/** Builds one archive: `node --eval builder `. */ +const builder = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { PGlite } from "@electric-sql/pglite"; +const [input, output] = process.argv.slice(1); +const db = new PGlite(); +try { + for (const statement of JSON.parse(readFileSync(input, "utf8"))) await db.exec(statement); + writeFileSync(output, new Uint8Array(await (await db.dumpDataDir("none")).arrayBuffer())); +} finally { + await db.close(); +} +`; + +const baseline = (ownerUserIds: readonly string[] = owners, organizationId = root) => + JSON.stringify({ version: 1, organizationId, ownerUserIds }); + +const Report = Schema.Struct({ + gate: Schema.Literals(["pass", "fail", "invalid", "error"]), + reason: Schema.optionalKey(Schema.String), + missingOwners: Schema.optionalKey(Schema.Array(Schema.String)), + unexpectedOwners: Schema.optionalKey(Schema.Number), + checks: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), +}); + +const workspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem, + path = yield* Path.Path, + processes = yield* ChildProcessSpawner.ChildProcessSpawner; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "executor-root-inspection-" }); + let files = 0; + const file = (contents: string | Uint8Array) => + Effect.gen(function* () { + const target = path.join(directory, `fixture-${files++}`); + yield* typeof contents === "string" + ? fs.writeFileString(target, contents) + : fs.writeFile(target, contents); + return target; + }); + /** + * A PGlite data-directory dump of a database built from `statements`, as export writes it. The + * dump is made in its own Node process with the repository's pinned PGlite. + */ + const dump = (statements: readonly string[]) => + Effect.gen(function* () { + const input = yield* file(JSON.stringify(statements)); + const output = `${input}.tar`; + yield* processes.string( + ChildProcess.make("node", ["--input-type=module", "--eval", builder, input, output], { + cwd: path.resolve("."), + }), + ); + return yield* fs.readFile(output); + }); + const archive = (statements: readonly string[]) => Effect.flatMap(dump(statements), file); + /** Run the gate as `node