History

Add hosted outbound HTTP policy

Route hosted plugin outbound requests through a shared Effect HttpClient layer that blocks local/private/metadata targets and re-checks redirects. Thread the executor-provided HTTP client through OpenAPI, GraphQL, MCP probing, and OAuth discovery, keep local harnesses on the normal fetch client, and split the restored security coverage into the relevant existing suites. Fix cloud migration scripts to invoke the local Drizzle binary under op run.

Rhys Sullivan committed 6 months ago

Commit 5fc67cd1 parent 745deac19 files · +559 −36Browse files

Showing the changes to apps/cloud/src (7 of 19 files). Show all

Unified · Split

7 files
  1. apps/cloud/src/mcp-session.e2e.node.test.ts +2 −0
  2. apps/cloud/src/services/__test-harness__/api-harness.ts +11 −2
  3. apps/cloud/src/services/executor.ts +9 −1
  4. apps/cloud/src/services/mcp-oauth.node.test.ts +63 −2
  5. apps/cloud/src/services/secrets-api.node.test.ts +6 −3
  6. apps/cloud/src/services/sources-api.node.test.ts +20 −0
  7. apps/cloud/src/services/tenant-isolation.node.test.ts +179 −5
apps/cloud/src/mcp-session.e2e.node.test.ts+2−0
@@ -33,6 +33,7 @@ import { · 32 unmodified lines
3333 createExecutor,
3434 definePlugin,
3535 } from "@executor-js/sdk";
36+import { FetchHttpClient } from "effect/unstable/http";
3637 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
3738 import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
3839 import executorConfig from "../executor.config";
@@ -117,6 +118,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO · 78 unmodified lines
117118 adapter,
118119 blobs,
119120 plugins,
121+ httpClientLayer: FetchHttpClient.layer,
120122 onElicitation: "accept-all",
121123 });
122124 });
apps/cloud/src/services/__test-harness__/api-harness.ts+11−2
@@ -26,7 +26,12 @@ import { · 25 unmodified lines
2626 } from "@executor-js/api/server";
2727 import { createExecutionEngine } from "@executor-js/execution";
2828 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
29−import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";
29+import {
30+ Scope,
31+ ScopeId,
32+ collectSchemas,
33+ createExecutor,
34+} from "@executor-js/sdk";
3035 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
3136 import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
3237
@@ -61,7 +66,10 @@ const defaultUserFor = (orgId: string) => `default_user_${orgId}`; · 28 unmodified lines
6166 // ---------------------------------------------------------------------------
6267
6368 const fakeVault = makeTestWorkOSVaultClient();
64−const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault });
69+const testPlugins = executorConfig.plugins({
70+ workosVaultClient: fakeVault,
71+});
72+const testHttpClientLayer = FetchHttpClient.layer;
6573
6674 const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>
6775 Effect.gen(function* () {
@@ -85,6 +93,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 17 unmodified lines
8593 adapter,
8694 blobs,
8795 plugins,
96+ httpClientLayer: testHttpClientLayer,
8897 onElicitation: "accept-all",
8998 });
9099 });
apps/cloud/src/services/executor.ts+9−1
@@ -10,7 +10,13 @@ · 9 unmodified lines
1010
1111 import { Effect } from "effect";
1212
13−import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";
13+import {
14+ Scope,
15+ ScopeId,
16+ collectSchemas,
17+ createExecutor,
18+ makeHostedHttpClientLayer,
19+} from "@executor-js/sdk";
1420 import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
1521
1622 import { env } from "cloudflare:workers";
@@ -58,6 +64,7 @@ export const createScopedExecutor = ( · 41 unmodified lines
5864 const { db } = yield* DbService;
5965
6066 const plugins = orgPlugins();
67+ const httpClientLayer = makeHostedHttpClientLayer();
6168 const schema = collectSchemas(plugins);
6269 const adapter = makePostgresAdapter({ db, schema });
6370 const blobs = makePostgresBlobStore({ db });
@@ -82,6 +89,7 @@ export const createScopedExecutor = ( · 18 unmodified lines
8289 adapter,
8390 blobs,
8491 plugins,
92+ httpClientLayer,
8593 onElicitation: "accept-all",
8694 });
8795 });
apps/cloud/src/services/mcp-oauth.node.test.ts+63−2
@@ -27,10 +27,10 @@ import { createServer, type Server } from "node:http"; · 26 unmodified lines
2727 import type { AddressInfo } from "node:net";
2828 import { createHash, randomBytes } from "node:crypto";
2929
30−import { Effect, Option, Schema } from "effect";
30+import { Effect, Option, Result, Schema } from "effect";
3131 import { ScopeId } from "@executor-js/sdk";
3232
33−import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
33+import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
3434
3535 // ---------------------------------------------------------------------------
3636 // Fake OAuth + MCP server
@@ -262,6 +262,67 @@ afterAll(async () => { · 225 unmodified lines
262262 // ---------------------------------------------------------------------------
263263
264264 describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
265+ it.effect(
266+ "start rejects a redirectUrl on a different origin before discovery",
267+ () =>
268+ Effect.gen(function* () {
269+ const org = `org_${crypto.randomUUID()}`;
270+ const scopeId = ScopeId.make(org);
271+ const start = Date.now();
272+
273+ const result = yield* asOrg(org, (client) =>
274+ client.oauth.start({
275+ params: { scopeId },
276+ payload: {
277+ endpoint: "https://example.test/api",
278+ redirectUrl: "https://other.example/cb",
279+ connectionId: "conn-foreign-redirect",
280+ tokenScope: String(scopeId),
281+ pluginId: "mcp",
282+ strategy: { kind: "dynamic-dcr" },
283+ },
284+ }),
285+ ).pipe(Effect.result);
286+
287+ expect(Result.isFailure(result)).toBe(true);
288+ expect(Date.now() - start).toBeLessThan(1000);
289+ }),
290+ { timeout: 30_000 },
291+ );
292+
293+ it.effect(
294+ "start rejects non-http redirectUrl schemes",
295+ () =>
296+ Effect.gen(function* () {
297+ const org = `org_${crypto.randomUUID()}`;
298+ const scopeId = ScopeId.make(org);
299+
300+ for (const redirectUrl of [
301+ "javascript:alert(1)",
302+ "data:text/html,<script>alert(1)</script>",
303+ ]) {
304+ const start = Date.now();
305+ const result = yield* asOrg(org, (client) =>
306+ client.oauth.start({
307+ params: { scopeId },
308+ payload: {
309+ endpoint: "https://example.test/api",
310+ redirectUrl,
311+ connectionId: `conn-${crypto.randomUUID().slice(0, 8)}`,
312+ tokenScope: String(scopeId),
313+ pluginId: "mcp",
314+ strategy: { kind: "dynamic-dcr" },
315+ },
316+ }),
317+ ).pipe(Effect.result);
318+
319+ expect(Result.isFailure(result)).toBe(true);
320+ expect(Date.now() - start).toBeLessThan(1000);
321+ }
322+ }),
323+ { timeout: 30_000 },
324+ );
325+
265326 it.effect(
266327 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",
267328 () =>
apps/cloud/src/services/secrets-api.node.test.ts+6−3
@@ -9,24 +9,27 @@ import { ScopeId, SecretId } from "@executor-js/sdk"; · 8 unmodified lines
99 import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";
1010
1111 describe("secrets api (HTTP)", () => {
12− it.effect("set → list → status round-trips a new secret without exposing plaintext", () =>
12+ it.effect("set → list → status returns secret metadata", () =>
1313 Effect.gen(function* () {
1414 const org = `org_${crypto.randomUUID()}`;
1515 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
1616
17+ const secretValue = "sk-test-abc";
1718 const setRef = yield* asOrg(org, (client) =>
1819 client.secrets.set({
1920 params: { scopeId: ScopeId.make(org) },
20− payload: { id: SecretId.make(id), name: "My API Token", value: "sk-test-abc" },
21+ payload: { id: SecretId.make(id), name: "My API Token", value: secretValue },
2122 }),
2223 );
2324 expect(setRef.id).toBe(id);
2425 expect(setRef.scopeId).toBe(org);
26+ expect(JSON.stringify(setRef)).not.toContain(secretValue);
2527
2628 const list = yield* asOrg(org, (client) =>
2729 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
2830 );
2931 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");
32+ expect(JSON.stringify(list)).not.toContain(secretValue);
3033
3134 const status = yield* asOrg(org, (client) =>
3235 client.secrets.status({
@@ -37,7 +40,7 @@ describe("secrets api (HTTP)", () => { · 4 unmodified lines
3740 }),
3841 );
3942
40− it.effect("does not expose a plaintext resolve endpoint", () =>
43+ it.effect("resolve is not available through the public API", () =>
4144 Effect.gen(function* () {
4245 const org = `org_${crypto.randomUUID()}`;
4346 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
apps/cloud/src/services/sources-api.node.test.ts+20−0
@@ -373,6 +373,26 @@ describe("sources api (HTTP)", () => { · 372 unmodified lines
373373 }),
374374 );
375375
376+ it.effect("openapi.addSpec accepts a public HTTP baseUrl in local mode", () =>
377+ Effect.gen(function* () {
378+ const org = `org_${crypto.randomUUID()}`;
379+
380+ const result = yield* asOrg(org, (client) =>
381+ client.openapi.addSpec({
382+ params: { scopeId: ScopeId.make(org) },
383+ payload: {
384+ targetScope: ScopeId.make(org),
385+ spec: MINIMAL_OPENAPI_SPEC,
386+ namespace: `ns_${crypto.randomUUID().replace(/-/g, "_")}`,
387+ baseUrl: "http://example.com",
388+ },
389+ }),
390+ );
391+
392+ expect(result.toolCount).toBe(1);
393+ }),
394+ );
395+
376396 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>
377397 Effect.gen(function* () {
378398 const server = yield* Effect.acquireRelease(
apps/cloud/src/services/tenant-isolation.node.test.ts+179−5
@@ -3,9 +3,9 @@ · 2 unmodified lines
33 // on the full cloud module graph.
44
55 import { describe, expect, it } from "@effect/vitest";
6−import { Effect } from "effect";
6+import { Effect, Result } from "effect";
77
8−import { ScopeId, SecretId } from "@executor-js/sdk";
8+import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
99
1010 import { asOrg } from "./__test-harness__/api-harness";
1111
@@ -23,7 +23,80 @@ const MINIMAL_OPENAPI_SPEC = JSON.stringify({ · 11 unmodified lines
2323 });
2424
2525 describe("tenant isolation (HTTP)", () => {
26− it.effect("sources.list does not leak across orgs", () =>
26+ it.effect("write requests cannot target another org scope", () =>
27+ Effect.gen(function* () {
28+ const orgA = `org_${crypto.randomUUID()}`;
29+ const orgB = `org_${crypto.randomUUID()}`;
30+
31+ const result = yield* asOrg(orgB, (client) =>
32+ client.secrets.set({
33+ params: { scopeId: ScopeId.make(orgA) },
34+ payload: {
35+ id: SecretId.make("planted"),
36+ name: "planted-by-org-b",
37+ value: "should-be-rejected",
38+ },
39+ }),
40+ ).pipe(Effect.result);
41+
42+ expect(Result.isFailure(result)).toBe(true);
43+
44+ const orgASecrets = yield* asOrg(orgA, (client) =>
45+ client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
46+ );
47+ expect(orgASecrets.map((s) => s.id)).not.toContain("planted");
48+ }),
49+ );
50+
51+ it.effect("read requests with another org scope still use the caller stack", () =>
52+ Effect.gen(function* () {
53+ const orgA = `org_${crypto.randomUUID()}`;
54+ const orgB = `org_${crypto.randomUUID()}`;
55+ const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
56+
57+ yield* asOrg(orgA, (client) =>
58+ client.secrets.set({
59+ params: { scopeId: ScopeId.make(orgA) },
60+ payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
61+ }),
62+ );
63+
64+ const result = yield* asOrg(orgB, (client) =>
65+ client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }),
66+ );
67+ expect(result.map((s) => s.id)).not.toContain(idA);
68+ }),
69+ );
70+
71+ it.effect("delete requests cannot remove another org secret", () =>
72+ Effect.gen(function* () {
73+ const orgA = `org_${crypto.randomUUID()}`;
74+ const orgB = `org_${crypto.randomUUID()}`;
75+ const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`;
76+
77+ yield* asOrg(orgA, (client) =>
78+ client.secrets.set({
79+ params: { scopeId: ScopeId.make(orgA) },
80+ payload: { id: SecretId.make(idA), name: "org-a only", value: "v" },
81+ }),
82+ );
83+
84+ yield* asOrg(orgB, (client) =>
85+ client.secrets.remove({
86+ params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
87+ }),
88+ ).pipe(Effect.result);
89+
90+ const status = yield* asOrg(orgA, (client) =>
91+ client.secrets.status({
92+ params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) },
93+ }),
94+ );
95+ expect(status.status).toBe("resolved");
96+ }),
97+ );
98+
99+ it.effect("sources.list is scoped to the caller org", () =>
27100 Effect.gen(function* () {
28101 const orgA = `org_${crypto.randomUUID()}`;
29102 const orgB = `org_${crypto.randomUUID()}`;
@@ -47,7 +120,7 @@ describe("tenant isolation (HTTP)", () => { · 17 unmodified lines
47120 }),
48121 );
49122
50− it.effect("tools.list does not leak across orgs", () =>
123+ it.effect("tools.list is scoped to the caller org", () =>
51124 Effect.gen(function* () {
52125 const orgA = `org_${crypto.randomUUID()}`;
53126 const orgB = `org_${crypto.randomUUID()}`;
@@ -68,6 +141,9 @@ describe("tenant isolation (HTTP)", () => { · 14 unmodified lines
68141 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),
69142 );
70143 expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA);
144+ for (const id of orgBTools.map((t) => t.id)) {
145+ expect(id).not.toContain(namespaceA);
146+ }
71147 }),
72148 );
73149
@@ -98,7 +174,7 @@ describe("tenant isolation (HTTP)", () => { · 24 unmodified lines
98174 }),
99175 );
100176
101− it.effect("secrets.list does not leak across orgs", () =>
177+ it.effect("secrets.list is scoped to the caller org", () =>
102178 Effect.gen(function* () {
103179 const orgA = `org_${crypto.randomUUID()}`;
104180 const orgB = `org_${crypto.randomUUID()}`;
@@ -168,6 +244,104 @@ describe("tenant isolation (HTTP)", () => { · 63 unmodified lines
168244 }),
169245 );
170246
247+ it.effect("secret usages are scoped to the caller stack", () =>
248+ Effect.gen(function* () {
249+ const orgA = `org_${crypto.randomUUID()}`;
250+ const orgB = `org_${crypto.randomUUID()}`;
251+ const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
252+ const secretIdA = SecretId.make(`sec_a_${crypto.randomUUID().slice(0, 8)}`);
253+
254+ yield* asOrg(orgA, (client) =>
255+ Effect.gen(function* () {
256+ yield* client.secrets.set({
257+ params: { scopeId: ScopeId.make(orgA) },
258+ payload: { id: secretIdA, name: "org-a token", value: "v" },
259+ });
260+ yield* client.openapi.addSpec({
261+ params: { scopeId: ScopeId.make(orgA) },
262+ payload: {
263+ targetScope: ScopeId.make(orgA),
264+ spec: MINIMAL_OPENAPI_SPEC,
265+ namespace: namespaceA,
266+ headers: {
267+ Authorization: {
268+ kind: "binding",
269+ slot: "auth:token",
270+ prefix: "Bearer ",
271+ },
272+ },
273+ },
274+ });
275+ yield* client.openapi.setSourceBinding({
276+ params: { scopeId: ScopeId.make(orgA) },
277+ payload: {
278+ sourceId: namespaceA,
279+ sourceScope: ScopeId.make(orgA),
280+ scope: ScopeId.make(orgA),
281+ slot: "auth:token",
282+ value: { kind: "secret", secretId: secretIdA },
283+ },
284+ });
285+ }),
286+ );
287+
288+ const usages = yield* asOrg(orgB, (client) =>
289+ client.secrets.usages({
290+ params: { scopeId: ScopeId.make(orgB), secretId: secretIdA },
291+ }),
292+ );
293+
294+ expect(usages).toEqual([]);
295+ }),
296+ );
297+
298+ it.effect("connection usages are scoped to the caller stack", () =>
299+ Effect.gen(function* () {
300+ const orgA = `org_${crypto.randomUUID()}`;
301+ const orgB = `org_${crypto.randomUUID()}`;
302+ const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`;
303+ const connectionIdA = ConnectionId.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`);
304+
305+ yield* asOrg(orgA, (client) =>
306+ Effect.gen(function* () {
307+ yield* client.openapi.addSpec({
308+ params: { scopeId: ScopeId.make(orgA) },
309+ payload: {
310+ targetScope: ScopeId.make(orgA),
311+ spec: MINIMAL_OPENAPI_SPEC,
312+ namespace: namespaceA,
313+ headers: {
314+ Authorization: {
315+ kind: "binding",
316+ slot: "auth:conn",
317+ prefix: "Bearer ",
318+ },
319+ },
320+ },
321+ });
322+ yield* client.openapi.setSourceBinding({
323+ params: { scopeId: ScopeId.make(orgA) },
324+ payload: {
325+ sourceId: namespaceA,
326+ sourceScope: ScopeId.make(orgA),
327+ scope: ScopeId.make(orgA),
328+ slot: "auth:conn",
329+ value: { kind: "connection", connectionId: connectionIdA },
330+ },
331+ });
332+ }),
333+ ).pipe(Effect.result);
334+
335+ const usages = yield* asOrg(orgB, (client) =>
336+ client.connections.usages({
337+ params: { scopeId: ScopeId.make(orgB), connectionId: connectionIdA },
338+ }),
339+ ).pipe(Effect.result);
340+
341+ if (Result.isSuccess(usages)) expect(usages.success).toEqual([]);
342+ }),
343+ );
344+
171345 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>
172346 Effect.gen(function* () {
173347 const orgA = `org_${crypto.randomUUID()}`;