329 lines · 12.8 KB · TypeScript
RawDownload
// Cloud-only: the connection OWNER model, with real multi-user organizations.// Every connection is filed under `owner: "org"` (shared with the whole tenant)// or `owner: "user"` (this subject's own). The org membership is built through// the real product flows — invite → accept-invitation, create-organization,// switch-organization — so the guarantees hold for genuine sessions:////   1. A user-owned connection is private to its creator, even from co-workers//      in the same org (personal OAuth tokens don't leak to colleagues).//   2. An org-owned connection IS visible to every member of that org — one//      admin pasting a shared API key serves the whole tenant.//   3. The same account in two orgs is two separate credential spaces.import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import type { HttpApiClient } from "effect/unstable/httpapi";import { composePluginApi } from "@executor-js/api/server";import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario";import { Api, Target } from "../src/services";import type { Identity, Target as TargetShape } from "../src/target"; const api = composePluginApi([openApiHttpPlugin()] as const);type Client = HttpApiClient.ForApi<typeof api>; const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */const pingSpec = JSON.stringify({  openapi: "3.0.3",  info: { title: "Ping API", version: "1.0.0" },  paths: {    "/ping": {      get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },    },  },}); /** Registers a fresh apiKey-authenticated integration for connections to bind to. */const registerIntegration = (client: Client) =>  Effect.gen(function* () {    const slug = IntegrationSlug.make(`owner-scn-${randomBytes(4).toString("hex")}`);    yield* client.openapi.addSpec({      payload: {        spec: { kind: "blob", value: pingSpec },        slug,        baseUrl: "http://127.0.0.1:59999", // never contacted during registration        authenticationTemplate: [          {            slug: "apiKey",            type: "apiKey",            headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },          },        ],      },    });    return slug;  }); const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); // ── Session plumbing over the real auth endpoints ───────────────────────────// These mirror what the product web app does: cookie-authenticated calls whose// responses re-seal the session when the active org changes. const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>  Effect.promise(async () => {    const response = await fetch(new URL(path, target.baseUrl), {      method: "POST",      headers: {        // The identity's own headers (session cookie + org selector) exactly        // as its API client would send them — org-scoped endpoints fail        // closed without the selector.        ...(identity.headers ?? {}),        "content-type": "application/json",        origin: new URL(target.baseUrl).origin,      },      body: JSON.stringify(body),    });    if (!response.ok) {      throw new Error(`${path} failed (${response.status}): ${await response.text()}`);    }    return response;  }); /** The identity re-bound to the refreshed session cookie a response set, *  scoped to `orgSelector` via the selector header (see switchOrg). */const withRefreshedSession = (  identity: Identity,  response: Response,  orgSelector: string,): Identity => {  const refreshed = (response.headers.getSetCookie?.() ?? [])    .find((header) => header.startsWith("wos-session="))    ?.split(";")[0];  if (!refreshed) throw new Error("response did not refresh the session cookie");  return {    ...identity,    headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector },  };}; /** The org selector this identity's requests carry — the same header the web *  client derives from the console URL (identities minted with an org carry *  it; org-scoped reads fail closed without one). */const orgSelectorOf = (identity: Identity): string => {  const selector = identity.headers?.[ORG_SELECTOR_HEADER];  if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);  return selector;}; /** Invite `member` into `admin`'s org and accept — the real invite flow. *  Returns the member identity with its requests scoped to that org. */const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>  Effect.gen(function* () {    const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {      email: member.credentials?.email,    });    const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };    const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {      invitationId: invitation.id,    });    return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));  }); /** Create another org for this account; returns the identity bound to it. */const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) =>  Effect.gen(function* () {    const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });    const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };    return withRefreshedSession(identity, response, created.id);  }); // `/api/auth/switch-organization` (session-cookie-based org switching) was// removed in #1000 (commit 1f9bfe06b): the URL is now the scope authority, not// the session. A request picks its active org via the `x-executor-organization`// header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`,// `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in// packages/core/sdk/src/server-connection.ts). Org-scoped API reads FAIL// CLOSED without it — there is deliberately no fallback to the session's// cookie-pinned org (that fallback served wrong-tenant data to multi-org// users). The header is a SELECTOR, not a trust boundary — the server// re-checks live membership — so attaching it directly to the identity here// is exactly what the real web client does from the console URL's slug.const ORG_SELECTOR_HEADER = "x-executor-organization"; /** Switch this account's active org; returns the identity scoped to it via *  the per-request org-selector header (no session mutation involved). */const switchOrg = (  _target: TargetShape,  identity: Identity,  organizationId: string,): Effect.Effect<Identity> =>  Effect.succeed({    ...identity,    headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },  }); /** The org this identity's requests are scoped to (selector header included, *  mirroring the web client). */const activeOrganizationId = (target: TargetShape, identity: Identity) =>  Effect.promise(async () => {    const response = await fetch(new URL("/api/auth/me", target.baseUrl), {      headers: identity.headers ?? {},    });    if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);    const body = (await response.json()) as { organization: { id: string } | null };    if (!body.organization) throw new Error("identity has no active organization");    return body.organization.id;  }); scenario(  "Connections · a user-owned connection is private to its creator, even inside the same org",  {},  Effect.gen(function* () {    const target = yield* Target;    const { client } = yield* Api;    const admin = yield* target.newIdentity();    const invitee = yield* target.newIdentity({ org: false });    const colleague = yield* joinOrg(target, admin, invitee);     const adminClient = yield* client(api, admin);    const colleagueClient = yield* client(api, colleague);     const integration = yield* registerIntegration(adminClient);    const name = freshConnectionName();    const secretValue = `personal-token-${randomBytes(8).toString("hex")}`;     // The admin stores a PERSONAL (user-owned) credential.    yield* adminClient.connections.create({      payload: {        owner: "user",        name,        integration,        template: TEMPLATE_API_KEY,        value: secretValue,      },    });     // A colleague in the SAME org sees neither the connection nor its bytes.    const colleagueUserList = yield* colleagueClient.connections.list({      query: { integration, owner: "user" },    });    expect(      colleagueUserList.map((connection) => connection.name),      "a co-worker's user-owned list has no trace of the admin's personal connection",    ).not.toContain(name);     const colleagueAll = yield* colleagueClient.connections.list({ query: {} });    expect(      colleagueAll.map((connection) => connection.name),      "the personal connection is absent from the co-worker's full list too",    ).not.toContain(name);    expect(      JSON.stringify(colleagueAll),      "the personal secret appears nowhere in the co-worker's view",    ).not.toContain(secretValue);     // And the creator still sees their own connection.    const adminUserList = yield* adminClient.connections.list({      query: { integration, owner: "user" },    });    expect(      adminUserList.map((connection) => connection.name),      "the creator still sees their own personal connection",    ).toContain(name);  }),); scenario(  "Connections · an org-owned connection is shared with every member of the org",  {},  Effect.gen(function* () {    const target = yield* Target;    const { client } = yield* Api;    const admin = yield* target.newIdentity();    const invitee = yield* target.newIdentity({ org: false });    const member = yield* joinOrg(target, admin, invitee);     const adminClient = yield* client(api, admin);    const memberClient = yield* client(api, member);     const integration = yield* registerIntegration(adminClient);    const name = freshConnectionName();     // The admin stores a SHARED (org-owned) credential.    yield* adminClient.connections.create({      payload: {        owner: "org",        name,        integration,        template: TEMPLATE_API_KEY,        value: "shared-org-key",      },    });     const adminOrgList = yield* adminClient.connections.list({      query: { integration, owner: "org" },    });    const memberOrgList = yield* memberClient.connections.list({      query: { integration, owner: "org" },    });    expect(      adminOrgList.map((connection) => connection.name),      "the admin sees the shared org connection",    ).toContain(name);    expect(      memberOrgList.map((connection) => connection.name),      "an invited member sees the shared org connection too",    ).toContain(name);  }),); scenario(  "Connections · the same account in two orgs gets two separate credential spaces",  {},  Effect.gen(function* () {    const target = yield* Target;    const { client } = yield* Api;    const userInOrgA = yield* target.newIdentity();    const orgAId = yield* activeOrganizationId(target, userInOrgA);    const clientA = yield* client(api, userInOrgA);     const integration = yield* registerIntegration(clientA);    const name = freshConnectionName();    yield* clientA.connections.create({      payload: {        owner: "user",        name,        integration,        template: TEMPLATE_API_KEY,        value: "value-in-org-a",      },    });     // The SAME account creates and switches into a second org.    const userInOrgB = yield* createAnotherOrg(      target,      userInOrgA,      `Second Org ${randomBytes(3).toString("hex")}`,    );    const clientB = yield* client(api, userInOrgB);     const orgBIntegrations = yield* clientB.integrations.list();    expect(      orgBIntegrations.map((entry) => entry.slug),      "the new org does not see org A's integration",    ).not.toContain(integration);     const orgBConnections = yield* clientB.connections.list({ query: {} });    expect(      orgBConnections.map((connection) => connection.name),      "the user's org-A connection is invisible from their second org",    ).not.toContain(name);     // Switching back to org A, the connection is still theirs.    const backInOrgA = yield* switchOrg(target, userInOrgB, orgAId);    const clientABack = yield* client(api, backInOrgA);    const orgAUserList = yield* clientABack.connections.list({      query: { integration, owner: "user" },    });    expect(      orgAUserList.map((connection) => connection.name),      "back in org A, the user-owned connection is still there",    ).toContain(name);  }),);