diff --git a/apps/cloud/src/api/api.ts b/apps/cloud/src/api/api.ts new file mode 100644 index 000000000..7076e07a4 --- /dev/null +++ b/apps/cloud/src/api/api.ts @@ -0,0 +1,17 @@ +// Cloud-side `HttpApi` definition. Pulled out of `layers.ts` / +// `protected-layers.ts` so handler files can reference it without +// creating a cycle (handlers → layers → handlers). + +import { CoreExecutorApi, InternalError } from "@executor/api"; +import { OpenApiGroup } from "@executor/plugin-openapi/api"; +import { McpGroup } from "@executor/plugin-mcp/api"; +import { GraphqlGroup } from "@executor/plugin-graphql/api"; + +import { OrgAuth, ScopeForbidden } from "../auth/middleware"; + +export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) + .add(McpGroup) + .add(GraphqlGroup) + .addError(InternalError) + .addError(ScopeForbidden) + .middleware(OrgAuth); diff --git a/apps/cloud/src/api/handlers/connections.ts b/apps/cloud/src/api/handlers/connections.ts new file mode 100644 index 000000000..5d0dc0f4d --- /dev/null +++ b/apps/cloud/src/api/handlers/connections.ts @@ -0,0 +1,54 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; +import type { ConnectionRef } from "@executor/sdk"; + +import { capture } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; + +import { assertScopeAccess } from "../../auth/scope-access"; +import { ProtectedCloudApi } from "../api"; + +const refToResponse = (ref: ConnectionRef) => ({ + id: ref.id, + scopeId: ref.scopeId, + provider: ref.provider, + kind: ref.kind, + identityLabel: ref.identityLabel, + accessTokenSecretId: ref.accessTokenSecretId, + refreshTokenSecretId: ref.refreshTokenSecretId, + expiresAt: ref.expiresAt, + oauthScope: ref.oauthScope, + createdAt: ref.createdAt.getTime(), + updatedAt: ref.updatedAt.getTime(), +}); + +export const ConnectionsHandlers = HttpApiBuilder.group( + ProtectedCloudApi, + "connections", + (handlers) => + handlers + .handle("list", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const refs = yield* executor.connections.list(); + return refs.map(refToResponse); + }), + ); + }), + ) + .handle("remove", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.connections.remove(path.connectionId); + return { removed: true }; + }), + ); + }), + ), +); diff --git a/apps/cloud/src/api/handlers/executions.ts b/apps/cloud/src/api/handlers/executions.ts new file mode 100644 index 000000000..778533ebe --- /dev/null +++ b/apps/cloud/src/api/handlers/executions.ts @@ -0,0 +1,76 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; + +import { capture, captureEngineError } from "@executor/api"; +import { ExecutionEngineService } from "@executor/api/server"; +import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; + +import { ProtectedCloudApi } from "../api"; + +// `/executions/...` — no scopeId path param. The engine is already +// bound to the request-scoped executor, which is pinned to the +// session's org at bootstrap. +export const ExecutionsHandlers = HttpApiBuilder.group(ProtectedCloudApi, "executions", (handlers) => + handlers + .handle("execute", ({ payload }) => + capture( + Effect.gen(function* () { + const engine = yield* ExecutionEngineService; + const outcome = yield* captureEngineError(engine.executeWithPause(payload.code)); + + if (outcome.status === "completed") { + const formatted = formatExecuteResult(outcome.result); + return { + status: "completed" as const, + text: formatted.text, + structured: formatted.structured, + isError: formatted.isError, + }; + } + + const formatted = formatPausedExecution(outcome.execution); + return { + status: "paused" as const, + text: formatted.text, + structured: formatted.structured, + }; + }), + ), + ) + .handle("resume", ({ path, payload }) => + capture( + Effect.gen(function* () { + const engine = yield* ExecutionEngineService; + const result = yield* captureEngineError( + engine.resume(path.executionId, { + action: payload.action, + content: payload.content as Record | undefined, + }), + ); + + if (!result) { + return yield* Effect.fail({ + _tag: "ExecutionNotFoundError" as const, + executionId: path.executionId, + }); + } + + if (result.status === "completed") { + const formatted = formatExecuteResult(result.result); + return { + text: formatted.text, + structured: formatted.structured, + isError: formatted.isError, + }; + } + + const formatted = formatPausedExecution(result.execution); + return { + text: formatted.text, + structured: formatted.structured, + isError: false, + }; + }), + ), + ), +); diff --git a/apps/cloud/src/api/handlers/index.ts b/apps/cloud/src/api/handlers/index.ts new file mode 100644 index 000000000..83a5c7651 --- /dev/null +++ b/apps/cloud/src/api/handlers/index.ts @@ -0,0 +1,28 @@ +// Cloud-side handler layer. Replaces `CoreHandlers` from +// `@executor/api/server` with copies that call +// `assertScopeAccess(path.scopeId)` before touching the request-scoped +// executor. The check compares the decoded `ScopeId` from the URL +// against `AuthContext` — so a caller authenticated as orgB who hits +// `/scopes/orgA/...` gets a typed `ScopeForbidden` (403) before any +// business logic runs. +// +// Endpoints without a `scopeId` in their route (`/scope`, `/executions/...`) +// skip the check — the executor is already pinned to the session org. + +import { Layer } from "effect"; + +import { ToolsHandlers } from "./tools"; +import { SourcesHandlers } from "./sources"; +import { SecretsHandlers } from "./secrets"; +import { ConnectionsHandlers } from "./connections"; +import { ScopeHandlers } from "./scope"; +import { ExecutionsHandlers } from "./executions"; + +export const CloudCoreHandlers = Layer.mergeAll( + ToolsHandlers, + SourcesHandlers, + SecretsHandlers, + ConnectionsHandlers, + ScopeHandlers, + ExecutionsHandlers, +); diff --git a/apps/cloud/src/api/handlers/scope.ts b/apps/cloud/src/api/handlers/scope.ts new file mode 100644 index 000000000..97d2ba237 --- /dev/null +++ b/apps/cloud/src/api/handlers/scope.ts @@ -0,0 +1,26 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; + +import { capture } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; + +import { ProtectedCloudApi } from "../api"; + +// `/scope` — no scopeId path param, so no `assertScopeAccess` call. +// Returns the caller's authenticated scope (the one the request-scoped +// executor was built for), which is already pinned to the session. +export const ScopeHandlers = HttpApiBuilder.group(ProtectedCloudApi, "scope", (handlers) => + handlers.handle("info", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const scope = executor.scopes.at(-1)!; + return { + id: scope.id, + name: scope.name, + dir: scope.name, + }; + }), + ), + ), +); diff --git a/apps/cloud/src/api/handlers/secrets.ts b/apps/cloud/src/api/handlers/secrets.ts new file mode 100644 index 000000000..44feed081 --- /dev/null +++ b/apps/cloud/src/api/handlers/secrets.ts @@ -0,0 +1,92 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; +import { SecretNotFoundError, SetSecretInput, type SecretRef } from "@executor/sdk"; + +import { capture } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; + +import { assertScopeAccess } from "../../auth/scope-access"; +import { ProtectedCloudApi } from "../api"; + +const refToResponse = (ref: SecretRef) => ({ + id: ref.id, + scopeId: ref.scopeId, + name: ref.name, + provider: ref.provider, + createdAt: ref.createdAt.getTime(), +}); + +export const SecretsHandlers = HttpApiBuilder.group(ProtectedCloudApi, "secrets", (handlers) => + handlers + .handle("list", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const refs = yield* executor.secrets.list(); + return refs.map(refToResponse); + }), + ); + }), + ) + .handle("status", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const status = yield* executor.secrets.status(path.secretId); + return { secretId: path.secretId, status }; + }), + ); + }), + ) + .handle("set", ({ path, payload }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const ref = yield* executor.secrets.set( + new SetSecretInput({ + id: payload.id, + scope: path.scopeId, + name: payload.name, + value: payload.value, + provider: payload.provider, + }), + ); + return refToResponse(ref); + }), + ); + }), + ) + .handle("resolve", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const value = yield* executor.secrets.get(path.secretId); + if (value === null) { + return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); + } + return { secretId: path.secretId, value }; + }), + ); + }), + ) + .handle("remove", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.secrets.remove(path.secretId); + return { removed: true }; + }), + ); + }), + ), +); diff --git a/apps/cloud/src/api/handlers/sources.ts b/apps/cloud/src/api/handlers/sources.ts new file mode 100644 index 000000000..0e7748f96 --- /dev/null +++ b/apps/cloud/src/api/handlers/sources.ts @@ -0,0 +1,95 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; +import { ToolId } from "@executor/sdk"; + +import { capture } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; + +import { assertScopeAccess } from "../../auth/scope-access"; +import { ProtectedCloudApi } from "../api"; + +export const SourcesHandlers = HttpApiBuilder.group(ProtectedCloudApi, "sources", (handlers) => + handlers + .handle("list", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const sources = yield* executor.sources.list(); + return sources.map((s) => ({ + id: s.id, + name: s.name, + kind: s.kind, + url: s.url, + runtime: s.runtime, + canRemove: s.canRemove, + canRefresh: s.canRefresh, + canEdit: s.canEdit, + })); + }), + ); + }), + ) + .handle("remove", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.sources.remove(path.sourceId); + return { removed: true }; + }), + ); + }), + ) + .handle("refresh", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.sources.refresh(path.sourceId); + return { refreshed: true }; + }), + ); + }), + ) + .handle("tools", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const tools = yield* executor.tools.list({ sourceId: path.sourceId }); + return tools.map((t) => ({ + id: ToolId.make(t.id), + pluginId: t.pluginId, + sourceId: t.sourceId, + name: t.name, + description: t.description, + mayElicit: t.annotations?.mayElicit, + })); + }), + ); + }), + ) + .handle("detect", ({ path, payload }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const results = yield* executor.sources.detect(payload.url); + return results.map((r) => ({ + kind: r.kind, + confidence: r.confidence, + endpoint: r.endpoint, + name: r.name, + namespace: r.namespace, + })); + }), + ); + }), + ), +); diff --git a/apps/cloud/src/api/handlers/tools.ts b/apps/cloud/src/api/handlers/tools.ts new file mode 100644 index 000000000..0a5c98616 --- /dev/null +++ b/apps/cloud/src/api/handlers/tools.ts @@ -0,0 +1,47 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; +import { ToolId, ToolNotFoundError } from "@executor/sdk"; + +import { capture } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; + +import { assertScopeAccess } from "../../auth/scope-access"; +import { ProtectedCloudApi } from "../api"; + +export const ToolsHandlers = HttpApiBuilder.group(ProtectedCloudApi, "tools", (handlers) => + handlers + .handle("list", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const tools = yield* executor.tools.list(); + return tools.map((t) => ({ + id: ToolId.make(t.id), + pluginId: t.pluginId, + sourceId: t.sourceId, + name: t.name, + description: t.description, + mayElicit: t.annotations?.mayElicit, + })); + }), + ); + }), + ) + .handle("schema", ({ path }) => + Effect.gen(function* () { + yield* assertScopeAccess(path.scopeId); + return yield* capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const schema = yield* executor.tools.schema(path.toolId); + if (schema === null) { + return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); + } + return schema; + }), + ); + }), + ), +); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index c9803127d..ed2c95b51 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -1,13 +1,11 @@ import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform"; import { Effect, Layer } from "effect"; -import { CoreExecutorApi, InternalError, observabilityMiddleware } from "@executor/api"; -import { CoreHandlers } from "@executor/api/server"; -import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; -import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; -import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api"; +import { observabilityMiddleware } from "@executor/api"; +import { OpenApiHandlers } from "@executor/plugin-openapi/api"; +import { McpHandlers } from "@executor/plugin-mcp/api"; +import { GraphqlHandlers } from "@executor/plugin-graphql/api"; -import { OrgAuth } from "../auth/middleware"; import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; import { @@ -21,16 +19,12 @@ import { OrgHttpApi } from "../org/compose"; import { OrgHandlers } from "../org/handlers"; import { ErrorCaptureLive } from "../observability"; +import { ProtectedCloudApi } from "./api"; import { CoreSharedServices } from "./core-shared-services"; +import { CloudCoreHandlers } from "./handlers"; export { CoreSharedServices }; -const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) - .add(McpGroup) - .add(GraphqlGroup) - .addError(InternalError) - .middleware(OrgAuth); - const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); const DbLive = DbService.Live; @@ -49,7 +43,7 @@ export const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 }) export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( Layer.provide( Layer.mergeAll( - CoreHandlers, + CloudCoreHandlers, OpenApiHandlers, McpHandlers, GraphqlHandlers, diff --git a/apps/cloud/src/api/protected-layers.ts b/apps/cloud/src/api/protected-layers.ts index 35e0afb25..7369d5eb8 100644 --- a/apps/cloud/src/api/protected-layers.ts +++ b/apps/cloud/src/api/protected-layers.ts @@ -6,29 +6,21 @@ import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform"; import { Layer } from "effect"; -import { - CoreExecutorApi, - InternalError, - observabilityMiddleware, -} from "@executor/api"; -import { CoreHandlers } from "@executor/api/server"; -import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; -import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; -import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api"; +import { observabilityMiddleware } from "@executor/api"; +import { OpenApiHandlers } from "@executor/plugin-openapi/api"; +import { McpHandlers } from "@executor/plugin-mcp/api"; +import { GraphqlHandlers } from "@executor/plugin-graphql/api"; -import { OrgAuth } from "../auth/middleware"; import { OrgAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { DbService } from "../services/db"; import { ErrorCaptureLive } from "../observability"; +import { ProtectedCloudApi } from "./api"; +import { CloudCoreHandlers } from "./handlers"; -export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) - .add(McpGroup) - .add(GraphqlGroup) - .addError(InternalError) - .middleware(OrgAuth); +export { ProtectedCloudApi }; const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); @@ -49,7 +41,7 @@ export const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 }) // harness builds its own api-live by merging this with a fake OrgAuth // layer; prod merges it with OrgAuthLive below. export const ProtectedCloudApiHandlers = Layer.mergeAll( - CoreHandlers, + CloudCoreHandlers, OpenApiHandlers, McpHandlers, GraphqlHandlers, diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index df8cfeaae..fb0ebb150 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -5,7 +5,12 @@ import { Effect, Layer, Redacted } from "effect"; -import { NoOrganization, OrgAuth, SessionAuth, Unauthorized } from "./middleware"; +import { + NoOrganization, + OrgAuth, + SessionAuth, + Unauthorized, +} from "./middleware"; import { WorkOSAuth } from "./workos"; export const SessionAuthLive = Layer.effect( diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index 1b90ece44..a85f189cb 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -44,6 +44,18 @@ export class NoOrganization extends Schema.TaggedError()( HttpApiSchema.annotations({ status: 403 }), ) {} +/** The `/scopes/:scopeId/...` path param does not belong to the + * caller. Raised by `OrgAuth` when an authenticated request targets a + * scope the caller's session can't act on (a different org's scope, + * or another user's `user-org:…` scope within the same org). Cloud- + * specific: `scopeId === organizationId` (or the `user-org:…` variant) + * is the invariant this check enforces. */ +export class ScopeForbidden extends Schema.TaggedError()( + "ScopeForbidden", + {}, + HttpApiSchema.annotations({ status: 403 }), +) {} + // --------------------------------------------------------------------------- // SessionAuth — resolves the WorkOS session cookie, provides SessionContext // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/scope-access.ts b/apps/cloud/src/auth/scope-access.ts new file mode 100644 index 000000000..cce94b576 --- /dev/null +++ b/apps/cloud/src/auth/scope-access.ts @@ -0,0 +1,31 @@ +// Per-handler scope guard. Cloud's invariant: a URL path param +// `scopeId` is either the caller's `organizationId` or the +// `user-org:${accountId}:${organizationId}` derivative. Every handler +// whose route carries a `scopeId` calls `assertScopeAccess(path.scopeId)` +// before touching the scoped executor — the argument is the decoded +// `ScopeId` from `path`, so the compiler catches typos (no string key +// lookups) and a renamed param on the API side fails the handler's +// types. + +import { Effect } from "effect"; + +import type { ScopeId } from "@executor/sdk"; + +import { AuthContext, ScopeForbidden } from "./middleware"; + +const userOrgScopeId = (accountId: string, organizationId: string) => + `user-org:${accountId}:${organizationId}`; + +export const assertScopeAccess = ( + scopeId: ScopeId, +): Effect.Effect => + Effect.gen(function* () { + const auth = yield* AuthContext; + if ( + scopeId === auth.organizationId || + scopeId === userOrgScopeId(auth.accountId, auth.organizationId) + ) { + return; + } + return yield* new ScopeForbidden(); + }); diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index c89580e09..ae075bb32 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -332,7 +332,7 @@ export const clientLayerForUser = (userId: string, orgId: string) => // Constructs an HttpApiClient bound to the given org, hands it to `body`, // and provides the org-scoped fetch layer in one step. Keeps per-test // Effect blocks focused on the actual assertions. -type ApiShape = typeof ProtectedCloudApi extends HttpApi.HttpApi< +export type ApiShape = typeof ProtectedCloudApi extends HttpApi.HttpApi< infer _Id, infer Groups, infer ApiError, diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 55e35c40d..d38b8be29 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -5,9 +5,10 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { ScopeId, SecretId } from "@executor/sdk"; +import { ConnectionId, ScopeId, SecretId, ToolId } from "@executor/sdk"; -import { asOrg } from "./__test-harness__/api-harness"; +import { ScopeForbidden } from "../auth/middleware"; +import { asOrg, type ApiShape } from "./__test-harness__/api-harness"; const MINIMAL_OPENAPI_SPEC = JSON.stringify({ openapi: "3.0.0", @@ -63,6 +64,52 @@ describe("tenant isolation (HTTP)", () => { }), ); + it.effect("sources.tools rejects cross-scope URLs with ScopeForbidden", () => + Effect.gen(function* () { + const orgA = `org_${crypto.randomUUID()}`; + const orgB = `org_${crypto.randomUUID()}`; + const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; + + // orgA installs a source with one `ping` tool. + yield* asOrg(orgA, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(orgA) }, + payload: { spec: MINIMAL_OPENAPI_SPEC, namespace: namespaceA }, + }), + ); + + // Sanity: orgA itself can see the tool through the per-source + // endpoint. If this drifts, the cross-org assertion below would + // pass for the wrong reason (source never got written). + const orgATools = yield* asOrg(orgA, (client) => + client.sources.tools({ + path: { scopeId: ScopeId.make(orgA), sourceId: namespaceA }, + }), + ); + expect(orgATools.length).toBeGreaterThan(0); + + // The realistic IDOR attack: orgB authenticates with its own + // session but crafts the URL with orgA's scopeId in the path. + // `OrgAuth` must reject this with `ScopeForbidden` (403) before + // the handler runs, so orgB never learns whether the source + // exists in orgA. + // + // `Effect.flip` turns the typed error channel into the success + // channel so we can match on it directly — if the call ever + // starts succeeding, the test fails in the yield (nothing to + // flip) rather than silently passing. + const error = yield* asOrg(orgB, (client) => + Effect.flip( + client.sources.tools({ + path: { scopeId: ScopeId.make(orgA), sourceId: namespaceA }, + }), + ), + ); + + expect(error).toBeInstanceOf(ScopeForbidden); + }), + ); + it.effect("openapi.getSource cannot reach another org's source by namespace", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; @@ -155,4 +202,94 @@ describe("tenant isolation (HTTP)", () => { expect(result._tag).toBe("Left"); }), ); + + // One row per guarded endpoint. `assertScopeAccess` runs before any + // lookup, so the dummy ids here never reach the DB — the request must + // 403 purely from the path's `scopeId` not matching the caller's + // session org. If someone deletes `yield* assertScopeAccess(...)` from + // a handler, that handler's row fails (either different error class or + // the call succeeds and `Effect.flip` has nothing to flip). + const crossScopeCalls: ReadonlyArray<{ + readonly name: string; + readonly call: ( + client: ApiShape, + victimScopeId: ScopeId, + ) => Effect.Effect; + }> = [ + { name: "tools.list", call: (c, s) => c.tools.list({ path: { scopeId: s } }) }, + { + name: "tools.schema", + call: (c, s) => + c.tools.schema({ path: { scopeId: s, toolId: ToolId.make("nope") } }), + }, + { name: "sources.list", call: (c, s) => c.sources.list({ path: { scopeId: s } }) }, + { + name: "sources.remove", + call: (c, s) => c.sources.remove({ path: { scopeId: s, sourceId: "nope" } }), + }, + { + name: "sources.refresh", + call: (c, s) => c.sources.refresh({ path: { scopeId: s, sourceId: "nope" } }), + }, + { + name: "sources.tools", + call: (c, s) => c.sources.tools({ path: { scopeId: s, sourceId: "nope" } }), + }, + { + name: "sources.detect", + call: (c, s) => + c.sources.detect({ + path: { scopeId: s }, + payload: { url: "https://example.com/spec.json" }, + }), + }, + { name: "secrets.list", call: (c, s) => c.secrets.list({ path: { scopeId: s } }) }, + { + name: "secrets.status", + call: (c, s) => + c.secrets.status({ path: { scopeId: s, secretId: SecretId.make("nope") } }), + }, + { + name: "secrets.set", + call: (c, s) => + c.secrets.set({ + path: { scopeId: s }, + payload: { id: SecretId.make("nope"), name: "nope", value: "nope" }, + }), + }, + { + name: "secrets.resolve", + call: (c, s) => + c.secrets.resolve({ path: { scopeId: s, secretId: SecretId.make("nope") } }), + }, + { + name: "secrets.remove", + call: (c, s) => + c.secrets.remove({ path: { scopeId: s, secretId: SecretId.make("nope") } }), + }, + { + name: "connections.list", + call: (c, s) => c.connections.list({ path: { scopeId: s } }), + }, + { + name: "connections.remove", + call: (c, s) => + c.connections.remove({ + path: { scopeId: s, connectionId: ConnectionId.make("nope") }, + }), + }, + ]; + + for (const { name, call } of crossScopeCalls) { + it.effect(`${name} rejects cross-scope URL with ScopeForbidden`, () => + Effect.gen(function* () { + const orgA = `org_${crypto.randomUUID()}`; + const orgB = `org_${crypto.randomUUID()}`; + const error = yield* asOrg(orgB, (client) => + Effect.flip(call(client, ScopeId.make(orgA))), + ); + expect(error).toBeInstanceOf(ScopeForbidden); + }), + ); + } });