| @@ -0,0 +1,178 @@ |
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// MCP cross-user (same-org) source isolation. |
| 3 | +// |
| 4 | +// Mirrors the prod executor scope stack: |
| 5 | +// inner = `user-org:${userId}:${orgId}` |
| 6 | +// outer = `${orgId}` |
| 7 | +// |
| 8 | +// Two users in the same org share the outer scope but have distinct inner |
| 9 | +// scopes. A source added at user A's inner scope must NOT appear when |
| 10 | +// user B calls `executor.sources.list()`. A source added at the shared |
| 11 | +// org scope MUST appear for both users (that's the whole point of the |
| 12 | +// outer scope — asserting it rules out a false positive where scope |
| 13 | +// filtering is simply broken). |
| 14 | +// --------------------------------------------------------------------------- |
| 15 | + |
| 16 | +import { describe, expect, it } from "@effect/vitest"; |
| 17 | +import { Effect } from "effect"; |
| 18 | + |
| 19 | +import { |
| 20 | + Scope, |
| 21 | + ScopeId, |
| 22 | + collectSchemas, |
| 23 | + createExecutor, |
| 24 | + makeInMemoryBlobStore, |
| 25 | +} from "@executor/sdk"; |
| 26 | +import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; |
| 27 | + |
| 28 | +import { mcpPlugin } from "./plugin"; |
| 29 | + |
| 30 | +// Shared memory adapter + blob store across the two executors — this is |
| 31 | +// what makes the leak possible in the first place. Production shares a |
| 32 | +// single Postgres across every request for an org. |
| 33 | +const makeSharedOrgExecutors = () => |
| 34 | + Effect.gen(function* () { |
| 35 | + const plugins = [mcpPlugin()] as const; |
| 36 | + const schema = collectSchemas(plugins); |
| 37 | + const adapter = makeMemoryAdapter({ schema }); |
| 38 | + const blobs = makeInMemoryBlobStore(); |
| 39 | + |
| 40 | + const ORG_ID = "org-acme"; |
| 41 | + const USER_A = "user-alice"; |
| 42 | + const USER_B = "user-bob"; |
| 43 | + |
| 44 | + const orgScopeId = ScopeId.make(ORG_ID); |
| 45 | + const aInnerId = ScopeId.make(`user-org:${USER_A}:${ORG_ID}`); |
| 46 | + const bInnerId = ScopeId.make(`user-org:${USER_B}:${ORG_ID}`); |
| 47 | + |
| 48 | + const orgScope = new Scope({ |
| 49 | + id: orgScopeId, |
| 50 | + name: "Acme", |
| 51 | + createdAt: new Date(), |
| 52 | + }); |
| 53 | + |
| 54 | + const makeFor = (innerId: ScopeId) => |
| 55 | + createExecutor({ |
| 56 | + scopes: [ |
| 57 | + new Scope({ id: innerId, name: "Personal", createdAt: new Date() }), |
| 58 | + orgScope, |
| 59 | + ], |
| 60 | + adapter, |
| 61 | + blobs, |
| 62 | + plugins, |
| 63 | + }); |
| 64 | + |
| 65 | + const execA = yield* makeFor(aInnerId); |
| 66 | + const execB = yield* makeFor(bInnerId); |
| 67 | + |
| 68 | + return { |
| 69 | + execA, |
| 70 | + execB, |
| 71 | + aInnerId: aInnerId as string, |
| 72 | + bInnerId: bInnerId as string, |
| 73 | + orgScopeId: orgScopeId as string, |
| 74 | + }; |
| 75 | + }); |
| 76 | + |
| 77 | +// Port 1 is reserved — addSource's discovery fails, but the source row |
| 78 | +// still persists. Same trick the existing multi-scope tests use so we |
| 79 | +// don't need an actual MCP server. |
| 80 | +const seedSource = ( |
| 81 | + addSource: (c: { |
| 82 | + readonly transport: "remote"; |
| 83 | + readonly scope: string; |
| 84 | + readonly name: string; |
| 85 | + readonly endpoint: string; |
| 86 | + readonly remoteTransport: "auto"; |
| 87 | + readonly namespace: string; |
| 88 | + }) => Effect.Effect<unknown, unknown>, |
| 89 | + args: { |
| 90 | + readonly scope: string; |
| 91 | + readonly name: string; |
| 92 | + readonly namespace: string; |
| 93 | + }, |
| 94 | +) => |
| 95 | + addSource({ |
| 96 | + transport: "remote", |
| 97 | + scope: args.scope, |
| 98 | + name: args.name, |
| 99 | + endpoint: `http://127.0.0.1:1/${args.namespace}`, |
| 100 | + remoteTransport: "auto", |
| 101 | + namespace: args.namespace, |
| 102 | + }).pipe(Effect.either); |
| 103 | + |
| 104 | +describe("MCP cross-user isolation within the same org", () => { |
| 105 | + it.effect("user B does not see user A's inner-scope MCP source", () => |
| 106 | + Effect.gen(function* () { |
| 107 | + const { execA, execB, aInnerId } = yield* makeSharedOrgExecutors(); |
| 108 | + |
| 109 | + // User A adds a personal source at their inner scope. |
| 110 | + yield* seedSource(execA.mcp.addSource, { |
| 111 | + scope: aInnerId, |
| 112 | + name: "Alice Personal", |
| 113 | + namespace: "alice_personal", |
| 114 | + }); |
| 115 | + |
| 116 | + // User B lists sources — must NOT see alice_personal. |
| 117 | + const bSources = yield* execB.sources.list(); |
| 118 | + const bIds = bSources.map((s) => s.id); |
| 119 | + |
| 120 | + expect(bIds).not.toContain("alice_personal"); |
| 121 | + }), |
| 122 | + ); |
| 123 | + |
| 124 | + it.effect("user B DOES see org-scope sources (sanity: filter not over-broad)", () => |
| 125 | + Effect.gen(function* () { |
| 126 | + const { execA, execB, orgScopeId } = yield* makeSharedOrgExecutors(); |
| 127 | + |
| 128 | + // An org-scope source — shared across everyone in the org. |
| 129 | + yield* seedSource(execA.mcp.addSource, { |
| 130 | + scope: orgScopeId, |
| 131 | + name: "Org Shared", |
| 132 | + namespace: "org_shared", |
| 133 | + }); |
| 134 | + |
| 135 | + const bSources = yield* execB.sources.list(); |
| 136 | + const bIds = bSources.map((s) => s.id); |
| 137 | + |
| 138 | + expect(bIds).toContain("org_shared"); |
| 139 | + }), |
| 140 | + ); |
| 141 | + |
| 142 | + it.effect("A sees A's source, B sees org's, neither sees the other's inner", () => |
| 143 | + Effect.gen(function* () { |
| 144 | + const { execA, execB, aInnerId, bInnerId, orgScopeId } = |
| 145 | + yield* makeSharedOrgExecutors(); |
| 146 | + |
| 147 | + yield* seedSource(execA.mcp.addSource, { |
| 148 | + scope: aInnerId, |
| 149 | + name: "Alice Personal", |
| 150 | + namespace: "alice_personal", |
| 151 | + }); |
| 152 | + yield* seedSource(execB.mcp.addSource, { |
| 153 | + scope: bInnerId, |
| 154 | + name: "Bob Personal", |
| 155 | + namespace: "bob_personal", |
| 156 | + }); |
| 157 | + yield* seedSource(execA.mcp.addSource, { |
| 158 | + scope: orgScopeId, |
| 159 | + name: "Org Shared", |
| 160 | + namespace: "org_shared", |
| 161 | + }); |
| 162 | + |
| 163 | + const aSources = yield* execA.sources.list(); |
| 164 | + const bSources = yield* execB.sources.list(); |
| 165 | + |
| 166 | + const aIds = aSources.map((s) => s.id); |
| 167 | + const bIds = bSources.map((s) => s.id); |
| 168 | + |
| 169 | + expect(aIds).toContain("alice_personal"); |
| 170 | + expect(aIds).toContain("org_shared"); |
| 171 | + expect(aIds).not.toContain("bob_personal"); |
| 172 | + |
| 173 | + expect(bIds).toContain("bob_personal"); |
| 174 | + expect(bIds).toContain("org_shared"); |
| 175 | + expect(bIds).not.toContain("alice_personal"); |
| 176 | + }), |
| 177 | + ); |
| 178 | +}); |