Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/connection-key-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@executor-js/sdk": patch
"@executor-js/hosted-server": patch
"@executor-js/app-data": patch
---

A scoped MCP connection stays scoped when its key calls the Executor app, including a nested `tools.call`. The API narrows that request to the calling connection and stamps the connection on the caller, so a further app invocation stays bound. Facet apps get the same connection on their outbound. Create, update, and mint stay inside the connection, including for an organization admin.
3 changes: 3 additions & 0 deletions apps/hosted/server/src/app-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { AppIdentity, AppGitAccess, AppAccessDenied } from "@executor-js/app-man
import { Effect, Layer, Schema } from "effect";
import { Base64 } from "effect/encoding";
import { ApiAuthentication, Authentication } from "./contracts/auth.ts";
import { McpAuthentication } from "./contracts/mcp.ts";
import { OrganizationTombstones } from "./contracts/organization-removal.ts";
import {
OrganizationReference,
Expand Down Expand Up @@ -70,6 +71,7 @@ export const hostedAppAccess = Layer.effect(
const api = yield* ApiAuthentication;
const auth = yield* Authentication;
const tombstones = yield* OrganizationTombstones;
const mcp = yield* McpAuthentication;
return (response, { endpoint }) =>
withOrganizationRequest(
(namespace) =>
Expand Down Expand Up @@ -113,6 +115,7 @@ export const hostedAppAccess = Layer.effect(
Effect.provideService(Authentication, auth),
Effect.provideService(ApiAuthentication, api),
Effect.provideService(OrganizationTombstones, tombstones),
Effect.provideService(McpAuthentication, mcp),
);
}),
);
Expand Down
14 changes: 12 additions & 2 deletions apps/hosted/server/src/implementation/caller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const apiCaller = (
readonly email?: string | null | undefined;
readonly access: Pick<OrganizationAccess, "organization">;
readonly key?: { readonly id: string } | undefined;
/** The scoped MCP connection the host attached to this request, when it arrived on one. */
readonly connectionId?: string | undefined;
},
issuer: string,
): Caller => ({
Expand All @@ -40,9 +42,15 @@ export const apiCaller = (
email: grant.email ?? null,
organizationId: grant.access.organization,
channel: "api",
...(grant.key === undefined
...(grant.key === undefined && grant.connectionId === undefined
? {}
: { provenance: { trusted: false, clientId: `apikey:${grant.key.id}` } }),
: {
provenance: {
trusted: false,
...(grant.key === undefined ? {} : { clientId: `apikey:${grant.key.id}` }),
...(grant.connectionId === undefined ? {} : { connectionId: grant.connectionId }),
},
}),
});

/** A signed-in person, in the dashboard or an app's own UI. */
Expand All @@ -51,13 +59,15 @@ export const personCaller = (
organization: string,
issuer: string,
channel: "ui" | "dashboard",
connectionId?: string,
): Caller => ({
kind: "human",
subject: person.userId,
issuer,
email: person.email ?? null,
organizationId: organization,
channel,
...(connectionId === undefined ? {} : { provenance: { trusted: false as const, connectionId } }),
});

/** A person who answered an approval request on the browser page or in the dashboard. */
Expand Down
80 changes: 73 additions & 7 deletions apps/hosted/server/src/implementation/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ import {
import { RequiredAction, CurrentAuthorization } from "../contracts/authorization.ts";
import {
fullAuthority,
isToolSelectionSubset,
permitsAction,
permitsApp,
permittedAppIds,
type AuthorizationPolicy,
type ToolSelection,
} from "@executor-js/authorization";
import { AppId, CurrentCaller } from "@executor-js/sdk/core";
import { AppId, callerConnectionHeader, CurrentCaller } from "@executor-js/sdk/core";
import {
ConnectionId,
connectionPermissions,
type ConnectionPolicy,
} from "@executor-js/mcp-auth/connections";
import { McpAuthentication } from "../contracts/mcp.ts";
import { apiCaller, personCaller } from "./caller.ts";
import { Context } from "effect";
import { currentResourceAuthority, visibleAccountsAs, visibleAppsAs } from "./resource-policy.ts";
Expand Down Expand Up @@ -106,6 +115,43 @@ const refuseRemoved = (organization: OrganizationId) =>
Effect.flatMap((gone) => (gone ? Effect.fail(new OrganizationForbidden()) : Effect.void)),
);

/** The connection's tools, no wider than the credential's own selection. */
const narrowToConnection = (grant: ToolSelection, policy: ConnectionPolicy): ToolSelection => {
const bound: ToolSelection = { kind: "tools", apps: [...connectionPermissions(policy)] };
if (grant.kind === "all" || isToolSelectionSubset(grant, bound)) return bound;
if (isToolSelectionSubset(bound, grant)) return grant;
return {
kind: "tools",
apps: bound.apps.filter((item) =>
isToolSelectionSubset(grant, { kind: "tools", apps: [item] }),
),
};
};

/**
* The host-attached calling connection, when this request arrived on one. A missing or foreign id
* is refused. The credential's actions stay; its tools narrow to that connection, and the caller
* carries the id so a nested app call binds the same connection.
*/
const scopedAuthority = (userId: string, organization: string, policy: AuthorizationPolicy) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const header = new Headers(request.headers).get(callerConnectionHeader);
if (header === null || header === "") return { policy, connectionId: undefined };
const id = yield* Schema.decodeUnknownEffect(ConnectionId)(header).pipe(
Effect.mapError(() => new OrganizationForbidden()),
);
const store = yield* McpAuthentication;
const found = (yield* store.connections.list({ userId, resource: organization })).find(
(connection) => connection.id === id,
);
if (found === undefined) return yield* new OrganizationForbidden();
return {
policy: { ...policy, tools: narrowToConnection(policy.tools, found.policy) },
connectionId: found.id,
};
});

/** Access resolves only the explicit route ID or slug. Shared session preferences never participate. */
export const withOrganizationRequest = <E, R>(
response: (
Expand Down Expand Up @@ -134,23 +180,32 @@ export const withOrganizationRequest = <E, R>(
callerSpanAttributes(grant.access.organization, grant.userId),
);
yield* refuseRemoved(grant.access.organization);
if (!permitsAction(grant.policy, action)) return yield* new OrganizationForbidden();
const scoped = yield* scopedAuthority(grant.userId, grant.access.organization, grant.policy);
if (!permitsAction(scoped.policy, action)) return yield* new OrganizationForbidden();
if (params.app !== undefined) {
const app = yield* Schema.decodeUnknownEffect(AppId)(params.app).pipe(
Effect.mapError(() => new OrganizationForbidden()),
);
if (!permitsApp(grant.policy, app)) return yield* new OrganizationForbidden();
if (!permitsApp(scoped.policy, app)) return yield* new OrganizationForbidden();
}
if (grant.key !== undefined)
yield* Effect.annotateCurrentSpan("executor.api_key.id", grant.key.id);
const scope = Effect.succeed(publishingScope(grant.organizationSlug));
return (yield* response(scope).pipe(
Effect.tapCause(ErrorReporter.report),
Effect.provideService(CurrentAuthorization, grant.policy),
Effect.provideService(CurrentAuthorization, scoped.policy),
Effect.provideService(CurrentOrganization, grant.access),
Effect.provideService(CurrentOrganizationNamespace, scope),
Effect.provideService(CurrentUserId, grant.userId),
Effect.provideService(CurrentCaller, apiCaller(grant, api.issuer)),
Effect.provideService(
CurrentCaller,
apiCaller(
scoped.connectionId === undefined
? grant
: { ...grant, connectionId: scoped.connectionId },
api.issuer,
),
),
Effect.provideService(CurrentUsage, { source: "api" }),
)).pipe(HttpServerResponse.setHeader("cache-control", "no-store"));
}
Expand Down Expand Up @@ -179,6 +234,13 @@ export const withOrganizationRequest = <E, R>(
owner: organizationOwner(organization),
role: membership.role,
};
const scoped = yield* scopedAuthority(principal.userId, organization, fullAuthority);
if (scoped.connectionId !== undefined && params.app !== undefined) {
const app = yield* Schema.decodeUnknownEffect(AppId)(params.app).pipe(
Effect.mapError(() => new OrganizationForbidden()),
);
if (!permitsApp(scoped.policy, app)) return yield* new OrganizationForbidden();
}
const scope = auth.organizationSlug(headers, organization).pipe(Effect.map(publishingScope));
return (yield* response(scope).pipe(
Effect.tapCause(ErrorReporter.report),
Expand All @@ -187,11 +249,13 @@ export const withOrganizationRequest = <E, R>(
Effect.provideService(CurrentUserId, principal.userId),
Effect.provideService(
CurrentCaller,
personCaller(principal, organization, auth.origin, "dashboard"),
scoped.connectionId === undefined
? personCaller(principal, organization, auth.origin, "dashboard")
: personCaller(principal, organization, auth.origin, "dashboard", scoped.connectionId),
),
Effect.provideService(CurrentUsage, { source: "dashboard" }),
Effect.provideService(CurrentPrincipal, principal),
Effect.provideService(CurrentAuthorization, fullAuthority),
Effect.provideService(CurrentAuthorization, scoped.policy),
)).pipe(HttpServerResponse.setHeader("cache-control", "no-store"));
});

Expand All @@ -202,6 +266,7 @@ export const requireOrganizationLive = Layer.effect(
const auth = yield* Authentication;
const api = yield* ApiAuthentication;
const tombstones = yield* OrganizationTombstones;
const mcp = yield* McpAuthentication;
return (response, { endpoint, group }) =>
withOrganizationRequest(
() => {
Expand All @@ -223,6 +288,7 @@ export const requireOrganizationLive = Layer.effect(
Effect.provideService(Authentication, auth),
Effect.provideService(ApiAuthentication, api),
Effect.provideService(OrganizationTombstones, tombstones),
Effect.provideService(McpAuthentication, mcp),
);
}),
);
Expand Down
10 changes: 10 additions & 0 deletions apps/hosted/server/src/implementation/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { HttpApiBuilder } from "effect/http-api";
import { HostedApi } from "../contracts/api.ts";
import { HostedExecutor } from "../contracts/executor.ts";
import { CurrentUserId, Forbidden } from "../contracts/auth.ts";
import { OrganizationForbidden } from "../contracts/organization.ts";
import { CurrentAuthorization } from "../contracts/authorization.ts";
import {
browserOnly,
Expand Down Expand Up @@ -218,6 +219,15 @@ export const callTool = (input: Parameters<Executor["tools"]["call"]>[0]) =>
result.status === "approval-required"
? Effect.fail(approvalRequired(result))
: Effect.succeed(result.value),
).pipe(
// A nested app call that the calling connection refused must stay a 403, not a tool 502.
Effect.mapError((error) =>
error._tag === "ToolCallFailed" &&
error.response?.status === 403 &&
error.response.code === "OrganizationForbidden"
? new OrganizationForbidden()
: error,
),
);
/**
* The signed-in person's own run from the dashboard. Its approval is saved with this issuer, so only
Expand Down
11 changes: 11 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6836,6 +6836,17 @@ export const scenarios = plan({
local: na("Local has one operator and no organization roles or managed Executor keys."),
},
},
adminConnectionKeyNoWidening: {
fixtures: "actors",
file: "mcp-connections.spec.ts",
title:
"An org admin's connection key cannot widen a connection or mint outside its scope, on the API or through the Executor app",
targets: {
"self-host": scheduled,
cloud: scheduled,
local: na("Local has one operator and no organization roles or managed Executor keys."),
},
},
memberOwnScopedConnections: {
fixtures: "actors",
file: "mcp-connections.spec.ts",
Expand Down
Loading
Loading