// --------------------------------------------------------------------------- // Organization resolution + authorization. // // One module for the cloud org auth-resolution path: // - `resolveOrganization` — local mirror with lazy WorkOS fallback. // - `authorizeOrganization` — membership check against the local membership // mirror, returns the resolved org. // // Deliberately billing-FREE: this module is reached by the MCP session DO bundle // (via `mcp/auth.ts`), which must not transitively import any billing config // (`autumn.config` / `atmn`). The free-organizations-per-user limit predicates — // which DO depend on the Autumn plan config — live in `extensions/billing/plans.ts`. // --------------------------------------------------------------------------- import { Clock, Effect } from "effect"; import { MemberDirectory } from "@executor-js/api/server"; import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; import { UserStoreService } from "./context"; import { ensureOrganizationBackfilled } from "./mirror-feeders"; import type { Organization } from "./user-store"; import { WorkOSClient } from "./workos"; // --------------------------------------------------------------------------- // Resolution — local mirror with lazy WorkOS fallback. // --------------------------------------------------------------------------- // // We keep a minimal local mirror of organizations so domain tables can // foreign-key against them and so we don't hit WorkOS on every request. // But the mirror can drift: a user's session can reference an org that was // created outside this app (or before the mirror existed). Rather than // proactively mirroring on every login — which was the source of the messy // callback flow we just untangled — we mirror lazily the first time an // unknown org is read. All other callers just do `getOrganization` and get // a self-healing lookup for free. // // URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a // row is inserted — `upsertOrganization` is the single mint point, so the // mirror-on-first-read below produces a slugged, routable org without any // read-path healing. export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); return yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: fresh.id, name: fresh.name, updatedAt: new Date(fresh.updatedAt), }), ); }); // --------------------------------------------------------------------------- // Deletion mark — the local step that revokes an organization. // --------------------------------------------------------------------------- // // Membership is authorized from the local mirror (below), so deleting the // WorkOS organization revokes nothing here by itself: the local membership // rows keep authorizing sessions until the local purge removes them, and a // purge that fails leaves them live. This mark is what revokes access, and it // is the FIRST step of cloud's deletion flow (`auth/handlers.ts` // deleteOrganization) — before the WorkOS delete and the purge, both of which // can fail — and what the `organization.deleted` event applies // (`workos-events-sync.ts`) when the org was deleted in the WorkOS dashboard // instead. Membership rows are left as they are; the mark alone refuses them. // Idempotent: a retry keeps the first mark. An org the mirror does not hold // is not marked (nothing to revoke), and `false` says so. export const markOrganizationDeleted = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const at = new Date(yield* Clock.currentTimeMillis); const marked = yield* users.use("markOrganizationDeleted", (s) => s.markOrganizationDeleted(organizationId, at), ); return marked !== null; }); // --------------------------------------------------------------------------- // Authorization — membership check against the local membership mirror. // --------------------------------------------------------------------------- // // The sealed session cookie carries an organizationId that WorkOS signed at // login / refresh time. WorkOS does NOT invalidate existing sessions when a // membership is revoked, and `session.authenticate()` validates the JWT // locally without hitting the API — so a removed user would keep full access // until their access token naturally expired (~10 min) if the session were // trusted on its own. // // To close that gap, membership is verified on every protected request // against the LOCAL mirror of WorkOS memberships (`memberships` join // `accounts`, read through the shared `MemberDirectory`) — never against // WorkOS itself, and unconditionally: there is no readiness gate and no // per-request WorkOS fallback. The mirror is not a cache with a TTL; it is a // replica whose freshness is defined by its feeders: // - login (`auth/handlers.ts` callback): the user and every membership WorkOS // lists for them, from the list the callback already fetches; // - write-through: every membership change Executor makes (create org, // invite, accept, remove, change role) lands in the mirror in the same // request, so a revocation through Executor is denied on the NEXT request; // - the WorkOS Events API reconciler (`workos-events-sync.ts`, every minute // by cron plus a signed webhook poke): changes made in the WorkOS // dashboard land within seconds. // The membership row must be `active`: a pending invitee is not a member, and a // deactivated member keeps their row but not their access. And the // organization must not be marked deleted (`organizations.deleted_at`): cloud's // deletion flow (`auth/handlers.ts` deleteOrganization) sets that mark FIRST, // before the WorkOS delete and the local purge, so an org whose deletion did // not finish refuses every session at once — its membership rows are still // there, live, until the purge removes them, and must not authorize anyone. // // The one-off backfill is complete and permanent, and an organization that // predates it is covered on demand (below), so there is nothing left for a // per-request readiness check to gate. What can still go wrong is the events // reconciler falling behind — a member revoked in the WorkOS dashboard would // keep a stale active row until it catches up. That is now an OPERATIONAL // concern, not a request-path fallback: the reconciler itself // (`workos-events-runner.ts`) checks its own drain lag after every run and // raises a Sentry error when it has stalled, so it is fixed by paging someone, // not by asking WorkOS on every request. The deploy gate // (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build // that trusts the mirror while it is unready, using the same rule // (`mirror-readiness-store.ts`). // // Completeness is PER ORGANIZATION. An organization whose row was minted // after the backfill ran — lazily by a request (`resolveOrganization`), or by // a first login — carries no `backfilled_at`, and the mirror holds only the // memberships login and write-through happened to record for it: a member // who has not signed in since would be refused on a row that was never // written. So the org row is read FIRST, and an unmarked live organization is // scanned from WorkOS (`ensureOrganizationBackfilled`: one membership listing // plus one `getUser` per member, then the mark) BEFORE its mirror is read — // the same on-demand scan the seat gates run. One-time per organization: the // scan marks the row, and this branch is never taken for it again. An // organization the mirror does not hold at all — one that predates the // mirror and that nobody has signed in to since (a CLI or MCP token names it, // and the JWT path has no login feeder), or one created in the WorkOS // dashboard — is reachable by neither the backfill (which lists the mirror's // organizations) nor the reconciler (which starts at the replay boundary), so // it is resolved on demand HERE: WorkOS is asked for the caller's own // membership in it first (`getUserOrgMembership`, a read scoped to this // caller — never a listing of the org), and only a member's answer mints the // row (`resolveOrganization`) and scans it as above. A non-member mints // nothing: a signed-in caller cannot create the row of an arbitrary WorkOS // organization by naming its id. An organization marked deleted is never // scanned: WorkOS no longer has it, and its rows are the purge's to remove, // not a listing's to refresh. // // Returns the resolved organization if the user currently holds an *active* // membership in it, otherwise null. Callers should treat null as "no access" // and route accordingly (onboarding page / 403). // // The ONE caller that may see a marked org is the deletion flow itself // (`deleted: "allow"`): an admin whose deletion failed after the mark must be // able to send it again to finish the purge, and their membership row is // still there to authorize exactly that. export interface AuthorizeOrganizationOptions { /** Whether an organization marked deleted resolves (`"allow"`) or is refused (default). */ readonly deleted?: "refuse" | "allow"; } /** The caller's active membership in the org, however it was read: only the role matters past this point. */ interface ActiveMembership { readonly role: string; } // The mirror read: the caller's row, active or nothing. const activeMembershipFromMirror = (userId: string, organizationId: string) => Effect.gen(function* () { const directory = yield* MemberDirectory; const membership = yield* directory.membership(userId, organizationId); if (!membership || membership.status !== "active") return null; const active: ActiveMembership = { role: membership.role }; return active; }); // The authorized organization, or null for one marked deleted (unless the // caller is the deletion flow). The membership already names the caller's // role — surfaced normalized so identity resolution can bind the executor's // workspace write permission without a second read. WorkOS issues `admin` / // `member`; anything unrecognized stays a plain member. const authorized = ( org: Organization, membership: ActiveMembership, options: AuthorizeOrganizationOptions, ) => { if (org.deletedAt !== null && options.deleted !== "allow") return null; const memberRole: "admin" | "member" = membership.role === "admin" ? "admin" : "member"; return { ...org, memberRole }; }; // The organization row for a caller, minted from WorkOS when the mirror // does not hold it — only for a caller WorkOS confirms as its member (see // above). `null` when the mirror has no row and WorkOS lists no membership. const heldOrResolvedForMember = (userId: string, organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (held) return held; const workos = yield* WorkOSClient; const membership = yield* workos.getUserOrgMembership(organizationId, userId); if (!membership) return null; yield* Effect.logInfo( "authorizeOrganization: organization not mirrored; resolving it from WorkOS for its member", { organizationId }, ); return yield* resolveOrganization(organizationId); }); /** The span every membership authorization runs under. */ export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization"; export const authorizeOrganization = ( userId: string, organizationId: string, options: AuthorizeOrganizationOptions = {}, ) => Effect.gen(function* () { const org = yield* heldOrResolvedForMember(userId, organizationId); if (!org) return null; // An unmarked live organization is scanned before its mirror is read // (see above). The row returned below still shows the mark as it was // read; nothing past this point reads it. A marked-deleted organization // is never scanned, so the deletion retry (`deleted: "allow"`) reaches // `authorized()` below with the membership row the purge has not removed // yet. if (org.deletedAt === null && org.backfilledAt === null) { yield* ensureOrganizationBackfilled(organizationId); } const membership = yield* activeMembershipFromMirror(userId, organizationId); if (!membership) return null; return authorized(org, membership, options); }).pipe(Effect.withSpan(AUTHORIZE_ORGANIZATION_SPAN)); // --------------------------------------------------------------------------- // Org SELECTOR — the URL is the scope authority, not the session. // --------------------------------------------------------------------------- // // Org-scoped requests carry the active org in this header, set by the web // client from the console URL's slug (the MCP plane carries the same idea in // its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the // readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a // SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks // membership against the mirror, so the worst a forged header does is name an // org the caller already belongs to. // // Why a header and not the session's `org_id`: a browser shares ONE cookie jar // across tabs, so a single session-pinned org makes "active org" a // browser-global — two tabs can't be in two orgs at once, and switching in one // silently re-scopes the other. Scoping per-request from the URL makes each // tab independent. export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER; /** The URL-pinned org selector for a request, or `null` to fall back to the session. */ export const orgSelectorFromRequest = (request: Request): string | null => request.headers.get(ORG_SELECTOR_HEADER); /** * Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the * caller actively belongs to, or `null`. A slug resolves through the local * mirror to its id first; ids pass straight through. Either way membership is * verified against the mirror via {@link authorizeOrganization}. */ export const authorizeOrganizationSelector = ( userId: string, selector: string, options: AuthorizeOrganizationOptions = {}, ) => Effect.gen(function* () { if (selector.startsWith("org_")) { return yield* authorizeOrganization(userId, selector, options); } const users = yield* UserStoreService; const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); if (!org) return null; return yield* authorizeOrganization(userId, org.id, options); });