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
33 createExecutor,33 createExecutor,
34 definePlugin,34 definePlugin,
35} from "@executor-js/sdk";35} from "@executor-js/sdk";
36import { FetchHttpClient } from "effect/unstable/http";
36import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";37import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
37import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";38import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
38import executorConfig from "../executor.config";39import executorConfig from "../executor.config";
@@ -117,6 +118,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO · 78 unmodified lines
117 adapter,118 adapter,
118 blobs,119 blobs,
119 plugins,120 plugins,
121 httpClientLayer: FetchHttpClient.layer,
120 onElicitation: "accept-all",122 onElicitation: "accept-all",
121 });123 });
122 });124 });
apps/cloud/src/services/__test-harness__/api-harness.ts+11−2
@@ -26,7 +26,12 @@ import { · 25 unmodified lines
26} from "@executor-js/api/server";26} from "@executor-js/api/server";
27import { createExecutionEngine } from "@executor-js/execution";27import { createExecutionEngine } from "@executor-js/execution";
28import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";28import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
29import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";29import {
30 Scope,
31 ScopeId,
32 collectSchemas,
33 createExecutor,
34} from "@executor-js/sdk";
30import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";35import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
31import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";36import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing";
32 37
@@ -61,7 +66,10 @@ const defaultUserFor = (orgId: string) => `default_user_${orgId}`; · 28 unmodified lines
61// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
62 67
63const fakeVault = makeTestWorkOSVaultClient();68const fakeVault = makeTestWorkOSVaultClient();
64const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault });69const testPlugins = executorConfig.plugins({
70 workosVaultClient: fakeVault,
71});
72const testHttpClientLayer = FetchHttpClient.layer;
65 73
66const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>74const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) =>
67 Effect.gen(function* () {75 Effect.gen(function* () {
@@ -85,6 +93,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 17 unmodified lines
85 adapter,93 adapter,
86 blobs,94 blobs,
87 plugins,95 plugins,
96 httpClientLayer: testHttpClientLayer,
88 onElicitation: "accept-all",97 onElicitation: "accept-all",
89 });98 });
90 });99 });
apps/cloud/src/services/executor.ts+9−1
@@ -10,7 +10,13 @@ · 9 unmodified lines
10 10
11import { Effect } from "effect";11import { Effect } from "effect";
12 12
13import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk";13import {
14 Scope,
15 ScopeId,
16 collectSchemas,
17 createExecutor,
18 makeHostedHttpClientLayer,
19} from "@executor-js/sdk";
14import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";20import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres";
15 21
16import { env } from "cloudflare:workers";22import { env } from "cloudflare:workers";
@@ -58,6 +64,7 @@ export const createScopedExecutor = ( · 41 unmodified lines
58 const { db } = yield* DbService;64 const { db } = yield* DbService;
59 65
60 const plugins = orgPlugins();66 const plugins = orgPlugins();
67 const httpClientLayer = makeHostedHttpClientLayer();
61 const schema = collectSchemas(plugins);68 const schema = collectSchemas(plugins);
62 const adapter = makePostgresAdapter({ db, schema });69 const adapter = makePostgresAdapter({ db, schema });
63 const blobs = makePostgresBlobStore({ db });70 const blobs = makePostgresBlobStore({ db });
@@ -82,6 +89,7 @@ export const createScopedExecutor = ( · 18 unmodified lines
82 adapter,89 adapter,
83 blobs,90 blobs,
84 plugins,91 plugins,
92 httpClientLayer,
85 onElicitation: "accept-all",93 onElicitation: "accept-all",
86 });94 });
87 });95 });
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
27import type { AddressInfo } from "node:net";27import type { AddressInfo } from "node:net";
28import { createHash, randomBytes } from "node:crypto";28import { createHash, randomBytes } from "node:crypto";
29 29
30import { Effect, Option, Schema } from "effect";30import { Effect, Option, Result, Schema } from "effect";
31import { ScopeId } from "@executor-js/sdk";31import { ScopeId } from "@executor-js/sdk";
32 32
33import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";33import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
34 34
35// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
36// Fake OAuth + MCP server36// Fake OAuth + MCP server
@@ -262,6 +262,67 @@ afterAll(async () => { · 225 unmodified lines
262// ---------------------------------------------------------------------------262// ---------------------------------------------------------------------------
263 263
264describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {264describe("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
265 it.effect(326 it.effect(
266 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",327 "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",
267 () =>328 () =>
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
9import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";9import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness";
10 10
11describe("secrets api (HTTP)", () => {11describe("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", () =>
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 const org = `org_${crypto.randomUUID()}`;14 const org = `org_${crypto.randomUUID()}`;
15 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;15 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;
16 16
17 const secretValue = "sk-test-abc";
17 const setRef = yield* asOrg(org, (client) =>18 const setRef = yield* asOrg(org, (client) =>
18 client.secrets.set({19 client.secrets.set({
19 params: { scopeId: ScopeId.make(org) },20 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 },
21 }),22 }),
22 );23 );
23 expect(setRef.id).toBe(id);24 expect(setRef.id).toBe(id);
24 expect(setRef.scopeId).toBe(org);25 expect(setRef.scopeId).toBe(org);
26 expect(JSON.stringify(setRef)).not.toContain(secretValue);
25 27
26 const list = yield* asOrg(org, (client) =>28 const list = yield* asOrg(org, (client) =>
27 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),29 client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
28 );30 );
29 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");31 expect(list.find((s) => s.id === id)?.name).toBe("My API Token");
32 expect(JSON.stringify(list)).not.toContain(secretValue);
30 33
31 const status = yield* asOrg(org, (client) =>34 const status = yield* asOrg(org, (client) =>
32 client.secrets.status({35 client.secrets.status({
@@ -37,7 +40,7 @@ describe("secrets api (HTTP)", () => { · 4 unmodified lines
37 }),40 }),
38 );41 );
39 42
40 it.effect("does not expose a plaintext resolve endpoint", () =>43 it.effect("resolve is not available through the public API", () =>
41 Effect.gen(function* () {44 Effect.gen(function* () {
42 const org = `org_${crypto.randomUUID()}`;45 const org = `org_${crypto.randomUUID()}`;
43 const id = `sec_${crypto.randomUUID().slice(0, 8)}`;46 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
373 }),373 }),
374 );374 );
375 375
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
376 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>396 it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () =>
377 Effect.gen(function* () {397 Effect.gen(function* () {
378 const server = yield* Effect.acquireRelease(398 const server = yield* Effect.acquireRelease(
apps/cloud/src/services/tenant-isolation.node.test.ts+179−5
@@ -3,9 +3,9 @@ · 2 unmodified lines
3// on the full cloud module graph.3// on the full cloud module graph.
4 4
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import { Effect } from "effect";6import { Effect, Result } from "effect";
7 7
8import { ScopeId, SecretId } from "@executor-js/sdk";8import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
9 9
10import { asOrg } from "./__test-harness__/api-harness";10import { asOrg } from "./__test-harness__/api-harness";
11 11
@@ -23,7 +23,80 @@ const MINIMAL_OPENAPI_SPEC = JSON.stringify({ · 11 unmodified lines
23});23});
24 24
25describe("tenant isolation (HTTP)", () => {25describe("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", () =>
27 Effect.gen(function* () {100 Effect.gen(function* () {
28 const orgA = `org_${crypto.randomUUID()}`;101 const orgA = `org_${crypto.randomUUID()}`;
29 const orgB = `org_${crypto.randomUUID()}`;102 const orgB = `org_${crypto.randomUUID()}`;
@@ -47,7 +120,7 @@ describe("tenant isolation (HTTP)", () => { · 17 unmodified lines
47 }),120 }),
48 );121 );
49 122
50 it.effect("tools.list does not leak across orgs", () =>123 it.effect("tools.list is scoped to the caller org", () =>
51 Effect.gen(function* () {124 Effect.gen(function* () {
52 const orgA = `org_${crypto.randomUUID()}`;125 const orgA = `org_${crypto.randomUUID()}`;
53 const orgB = `org_${crypto.randomUUID()}`;126 const orgB = `org_${crypto.randomUUID()}`;
@@ -68,6 +141,9 @@ describe("tenant isolation (HTTP)", () => { · 14 unmodified lines
68 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),141 client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }),
69 );142 );
70 expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA);143 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 }
71 }),147 }),
72 );148 );
73 149
@@ -98,7 +174,7 @@ describe("tenant isolation (HTTP)", () => { · 24 unmodified lines
98 }),174 }),
99 );175 );
100 176
101 it.effect("secrets.list does not leak across orgs", () =>177 it.effect("secrets.list is scoped to the caller org", () =>
102 Effect.gen(function* () {178 Effect.gen(function* () {
103 const orgA = `org_${crypto.randomUUID()}`;179 const orgA = `org_${crypto.randomUUID()}`;
104 const orgB = `org_${crypto.randomUUID()}`;180 const orgB = `org_${crypto.randomUUID()}`;
@@ -168,6 +244,104 @@ describe("tenant isolation (HTTP)", () => { · 63 unmodified lines
168 }),244 }),
169 );245 );
170 246
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
171 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>345 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>
172 Effect.gen(function* () {346 Effect.gen(function* () {
173 const orgA = `org_${crypto.randomUUID()}`;347 const orgA = `org_${crypto.randomUUID()}`;