From 2c3d52f25b915f8ce46c9da6dcb34b5957b27578 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:00:17 +0000 Subject: [PATCH] Restrict workspace-level settings to admins --- .changeset/workspace-writes-admin-only.md | 21 ++ .../api/protected-api-key-auth.node.test.ts | 3 + .../src/api/protected-jwt-auth.node.test.ts | 3 + apps/cloud/src/auth/organization.ts | 9 +- apps/cloud/src/auth/workos-auth-provider.ts | 3 + apps/cloud/src/mcp/session-durable-object.ts | 16 +- apps/host-selfhost/src/admin/require-admin.ts | 6 +- apps/host-selfhost/src/auth/identity.ts | 24 +- apps/host-selfhost/src/mcp/auth.ts | 20 ++ apps/host-selfhost/src/multi-user.test.ts | 23 +- packages/core/api/src/connections/api.ts | 6 +- packages/core/api/src/integrations/api.ts | 7 +- packages/core/api/src/oauth/api.ts | 9 +- packages/core/api/src/policies/api.ts | 14 +- .../src/server/execution-stack-middleware.ts | 3 + .../core/api/src/server/execution-stack.ts | 12 +- packages/core/api/src/server/identity.ts | 11 + packages/core/api/src/server/mcp-build.ts | 7 +- .../core/api/src/server/scoped-executor.ts | 9 +- packages/core/sdk/src/errors.ts | 26 ++ packages/core/sdk/src/executor.ts | 105 +++++-- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/oauth-client.ts | 13 +- packages/core/sdk/src/oauth-service.ts | 25 +- packages/core/sdk/src/org-writes.test.ts | 258 ++++++++++++++++++ packages/core/sdk/src/plugin.ts | 35 ++- packages/core/sdk/src/shared.ts | 1 + packages/core/sdk/src/test-config.ts | 5 + .../src/mcp/agent-session-durable-object.ts | 8 + packages/hosts/mcp/src/seams.ts | 5 + packages/plugins/graphql/src/api/group.ts | 7 +- packages/plugins/graphql/src/sdk/plugin.ts | 3 +- packages/plugins/mcp/src/api/group.ts | 8 +- packages/plugins/mcp/src/sdk/plugin.ts | 16 +- packages/plugins/openapi/src/api/group.ts | 3 + packages/plugins/openapi/src/sdk/plugin.ts | 9 +- 36 files changed, 655 insertions(+), 79 deletions(-) create mode 100644 .changeset/workspace-writes-admin-only.md create mode 100644 packages/core/sdk/src/org-writes.test.ts diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md new file mode 100644 index 0000000000..2b58111a3b --- /dev/null +++ b/.changeset/workspace-writes-admin-only.md @@ -0,0 +1,21 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-graphql": minor +"@executor-js/plugin-mcp": minor +"@executor-js/plugin-openapi": minor +--- + +**Workspace-level settings are now admin-only** + +The executor binding gains `orgWrites: "allowed" | "denied"`. Hosts derive it +from the acting member's role (cloud: WorkOS membership role; self-host: +Better Auth org membership role), and a plain member's binding refuses every +user-intent workspace-level mutation with the new `OrgWriteDeniedError` +(HTTP 403): org-owned tool policies, workspace-shared connections, org OAuth +apps and org connect flows, and integration-catalog changes (add, update, +remove, health check). + +Using workspace resources is unchanged for members: reads, tool execution over +shared connections, and the operational writes those imply (token refresh, +tool-catalog re-sync, config-rewrite healing) keep working. Hosts with no role +model (local, the CLI, embedded SDK use) default to `"allowed"`. diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 6027133349..f408f55f81 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -98,6 +98,9 @@ describe("protected API key auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index 2120c9004c..e4d2c18ca0 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -119,6 +119,9 @@ describe("protected JWT (device-login) auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 51944cfbbe..d41e3eb325 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -86,7 +86,14 @@ export const authorizeOrganization = (userId: string, organizationId: string) => ); if (!active) return null; - return yield* resolveOrganization(organizationId); + const org = yield* resolveOrganization(organizationId); + // The membership row already names the caller's role — surface it + // normalized so identity resolution can bind the executor's workspace + // write permission without a second WorkOS call. WorkOS issues + // `admin` / `member`; anything unrecognized stays a plain member. + const roleSlug = (active as { readonly role?: { readonly slug?: string } }).role?.slug; + const memberRole: "admin" | "member" = roleSlug === "admin" ? "admin" : "member"; + return { ...org, memberRole }; }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 95742038f3..40d947f5ca 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -154,6 +154,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -253,6 +254,7 @@ export const resolveBearerAuth = ( name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -326,6 +328,7 @@ export const resolveSessionPrincipal = (request: Request) => name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 31d6d4bf6b..7d6862f417 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu // `SessionAuthLive` instead.) import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; -import { resolveOrganization } from "../auth/organization"; +import { authorizeOrganization } from "../auth/organization"; import { DbService, combinedSchema, @@ -214,7 +214,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const dbHandle = makeEphemeralDb(); return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); + // Membership was already verified by the worker's per-request auth; this + // re-check is where the session learns the member's WORKSPACE ROLE, so + // the executor it builds can bind `orgWrites` (a member may use org + // connections but not configure workspace-level state). The role is + // baked into the persisted meta: a demotion applies from the next + // session init, not mid-session. + const org = yield* authorizeOrganization(token.userId, token.organizationId); if (!org) { return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); } @@ -223,6 +229,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase +export const isPrivileged = (role: string): boolean => role .split(",") .map((part) => part.trim()) diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index 932e90230c..0a1abcb7df 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "./better-auth"; // --------------------------------------------------------------------------- @@ -49,13 +50,18 @@ export const betterAuthIdentityLayer: Layer.Layer auth.api.getSession({ headers: request.headers }), ); + // The credential shape that resolved the session — the SAME headers + // are what the membership-role lookup below must present. + let sessionHeaders: Headers | Record = request.headers; if (!resolved) { const token = bearerToken(request.headers); if (token) { + const apiKeyHeaders = { "x-api-key": token }; resolved = yield* Effect.tryPromise({ - try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + try: () => auth.api.getSession({ headers: apiKeyHeaders }), catch: () => "api-key session lookup failed", }).pipe(Effect.orElseSucceed(() => null)); + sessionHeaders = apiKeyHeaders; } } // No session resolved from any credential shape -> unauthenticated. @@ -66,6 +72,21 @@ export const betterAuthIdentityLayer: Layer.Layer + auth.api.getActiveMemberRole({ + headers: sessionHeaders, + query: { organizationId: resolvedOrganizationId }, + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { kind: "member" as const, accountId: resolved.user.id, @@ -79,6 +100,7 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), + orgRole, }; }), }); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index abd07ba384..ddfd24c49c 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -11,6 +11,7 @@ import { type Principal, } from "@executor-js/host-mcp"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "../auth/better-auth"; import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; @@ -205,6 +206,24 @@ export const selfHostMcpAuth: Layer.Layer context.internalAdapter.findUserById(userId)); if (!user) return null; + // The workspace role, read from the INSTANCE org's membership row + // (an OAuth token carries no session, so the header-based + // `getActiveMemberRole` gate is out of reach — the adapter query + // answers the same question against the same table). FAIL CLOSED to + // "member": an infra fault demotes rather than escalates. + const membership = yield* Effect.promise(() => + context.adapter.findOne<{ readonly role?: string | null }>({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: organizationId }, + ], + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership?.role != null && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { accountId: user.id, // Single-org self-host: OAuth tokens carry no active org, so pin to @@ -216,6 +235,7 @@ export const selfHostMcpAuth: Layer.Layer => { - const inviteCode = await mintInviteCode(handler); +const signUp = async (email: string, role: "admin" | "member" = "member"): Promise => { + const inviteCode = await mintInviteCode(handler, role); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", @@ -136,7 +136,9 @@ const runCode = async (token: string, code: string) => { }; test("multiple accounts share one org but isolate per-user connections", async () => { - const alice = await signUp("alice@multi.test"); + // Workspace-level setup (the catalog, org-shared connections) is admin-only, + // so Alice joins as an admin; Bob stays a plain member. + const alice = await signUp("alice@multi.test", "admin"); const bob = await signUp("bob@multi.test"); // Same single org for both members. @@ -147,6 +149,21 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); + // A plain member cannot register integrations or mint workspace-shared + // connections — 403 from the executor's workspace-write gate. + expect((await addIntegration(bob, "tiny2")).status).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "org", + name: "bob-shared", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(403); + // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). expect( diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb3..cd7bb7698e 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -22,6 +22,7 @@ import { IntegrationSlug, InternalError, InvalidConnectionInputError, + OrgWriteDeniedError, OAuthClientSlug, Owner, ProviderItemId, @@ -195,6 +196,7 @@ export const ConnectionsApi = HttpApiGroup.make("connections") IntegrationNotFound, CredentialProviderNotRegistered, InvalidConnectionInput, + OrgWriteDeniedError, ], }), ) @@ -210,14 +212,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") params: ConnectionParams, payload: UpdateConnectionPayload, success: ConnectionResponse, - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { params: ConnectionParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index 15161119e9..5654884b14 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -18,6 +18,7 @@ import { IntegrationRemovalNotAllowedError, IntegrationSlug, InternalError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -132,14 +133,14 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: UpdateIntegrationPayload, success: IntegrationResponse, - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/integrations/:slug", { params: IntegrationParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, IntegrationRemovalNotAllowed], + error: [InternalError, IntegrationRemovalNotAllowed, OrgWriteDeniedError], }), ) .add( @@ -172,6 +173,6 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: SetHealthCheckPayload, success: Schema.Struct({ ok: Schema.Boolean }), - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index dcb71ccd96..76fc3c8d28 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -27,6 +27,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + OrgWriteDeniedError, Owner, ProviderKey, } from "@executor-js/sdk/shared"; @@ -259,14 +260,14 @@ export const OAuthApi = HttpApiGroup.make("oauth") HttpApiEndpoint.post("createClient", "/oauth/clients", { payload: CreateClientPayload, success: CreateClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", { payload: RegisterDynamicPayload, success: RegisterDynamicResponse, - error: [InternalError, OAuthRegisterDynamic], + error: [InternalError, OAuthRegisterDynamic, OrgWriteDeniedError], }), ) .add( @@ -280,14 +281,14 @@ export const OAuthApi = HttpApiGroup.make("oauth") params: RemoveClientParams, payload: RemoveClientPayload, success: RemoveClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("start", "/oauth/start", { payload: StartPayload, success: StartResponse, - error: [InternalError, OAuthStart], + error: [InternalError, OAuthStart, OrgWriteDeniedError], }), ) .add( diff --git a/packages/core/api/src/policies/api.ts b/packages/core/api/src/policies/api.ts index a3b9f76de2..5f7915265a 100644 --- a/packages/core/api/src/policies/api.ts +++ b/packages/core/api/src/policies/api.ts @@ -8,7 +8,13 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, Owner, PolicyId, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; +import { + InternalError, + OrgWriteDeniedError, + Owner, + PolicyId, + ToolPolicyActionSchema, +} from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Params @@ -63,7 +69,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") HttpApiEndpoint.post("create", "/policies", { payload: CreateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -71,7 +77,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: UpdateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -79,6 +85,6 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: RemoveToolPolicyPayload, success: Schema.Struct({ removed: Schema.Boolean }), - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index b3e6749b83..db64b2d57e 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -239,6 +239,9 @@ export const makeExecutionStackMiddleware = < resolved.accountId, resolved.organizationId, resolved.organizationName, + // A plain member binds with workspace writes denied; an admin — + // or a host with no role model (`orgRole` absent) — binds allowed. + { orgWrites: resolved.orgRole === "member" ? "denied" : "allowed" }, ).pipe( Effect.provide(options.stackLayer), Effect.provideService(RequestWebOrigin, { diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index eb7756a51d..a94022d61f 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -112,7 +112,12 @@ export const makeExecutionStack = < accountId: string, organizationId: string, organizationName: string, - options?: { readonly mcpResource?: McpResource }, + options?: { + readonly mcpResource?: McpResource; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ + readonly orgWrites?: "allowed" | "denied"; + }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, StorageFailure, @@ -123,7 +128,10 @@ export const makeExecutionStack = < accountId, organizationId, organizationName, - { plugins: { mcpResource: options?.mcpResource } }, + { + plugins: { mcpResource: options?.mcpResource }, + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + }, ).pipe(Effect.withSpan("executor.stack.scoped_executor")); const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( Effect.withSpan("executor.stack.code_executor"), diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 04e4f23101..ecc232d178 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -48,6 +48,17 @@ export interface Principal { readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + /** + * The member's NORMALIZED workspace role, when the host resolves one: + * `"admin"` may configure workspace-level state (org-owned rows, the + * integration catalog), `"member"` may only use it. Cloud maps its WorkOS + * membership role (`admin` / `member`); self-host maps Better Auth's org + * membership role (`owner` and `admin` → `"admin"`). ABSENT means the host + * has no role model (local's single user, test fakes) and the middleware + * binds the executor with workspace writes allowed — hosts that DO + * distinguish roles must always set it. + */ + readonly orgRole?: "admin" | "member"; } /** diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302faca..e9f4bed350 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -48,7 +48,12 @@ export const makeMcpBuildServer = principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { + mcpResource: options?.resource, + // A plain member binds with workspace writes denied; an admin — or + // a host with no role model (`orgRole` absent) — binds allowed. + orgWrites: principal.orgRole === "member" ? "denied" : "allowed", + }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and // hosts that can't know their public URL at boot leave it unset — in diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index ea0e33ce61..5f7f31d8c1 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -218,7 +218,13 @@ export const makeScopedExecutor = < // `EngineStackIdentity` (the engine decorator still wants it); not part of the // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, - options?: { readonly plugins?: PluginsProviderContext }, + options?: { + readonly plugins?: PluginsProviderContext; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's + * role; omitted -> allowed (hosts with no role model). */ + readonly orgWrites?: "allowed" | "denied"; + }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db, blobs } = yield* DbProvider.asEffect().pipe( @@ -285,6 +291,7 @@ export const makeScopedExecutor = < fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, onElicitation: "accept-all", + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, coreTools: { diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 7f28ab58e7..89155bd644 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -142,6 +142,32 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( + "OrgWriteDeniedError", + {}, + { httpApiStatus: 403 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "org_write_denied"; + + override get message(): string { + return "Workspace-level changes require a workspace admin."; + } + + get userMessage(): string { + return this.message; + } +} + export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 56cb2e2997..3b220a2726 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -72,6 +72,7 @@ import { InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, + OrgWriteDeniedError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, @@ -277,10 +278,13 @@ export type Executor = { readonly update: ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; readonly detect: ( url: string, ) => Effect.Effect; @@ -305,7 +309,7 @@ export type Executor = { readonly set: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; }; }; @@ -317,6 +321,7 @@ export type Executor = { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -329,10 +334,10 @@ export type Executor = { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< @@ -379,9 +384,15 @@ export type Executor = { readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; readonly resolve: (address: ToolAddress) => Effect.Effect; }; @@ -671,6 +682,24 @@ export interface ExecutorConfig => + config.orgWrites === "denied" && (owner === undefined || owner === "org") + ? Effect.fail(new OrgWriteDeniedError()) + : Effect.void; + // Built-in core-tools plugin: agent-facing static tools over the v2 surface. const plugins: readonly AnyPlugin[] = config.coreTools ? ([ @@ -2308,7 +2348,7 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { const now = new Date(); @@ -2329,6 +2369,10 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(); const now = new Date(); const set: Record = { updated_at: now }; if (patch.name !== undefined) set.name = patch.name; @@ -2382,7 +2427,7 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { const existing = yield* findIntegrationRow(slug); if (!existing) return yield* new IntegrationNotFoundError({ slug }); @@ -2391,9 +2436,13 @@ export const createExecutor = => + ): Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + > => transaction( Effect.gen(function* () { + yield* guardOrgWrite(); const existing = yield* findIntegrationRow(slug); if (!existing) return null; if (!existing.can_remove) { @@ -2481,8 +2530,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(); const row = yield* findIntegrationRow(slug); if (!row) return yield* new IntegrationNotFoundError({ slug }); yield* core.updateMany("integration", { @@ -2722,9 +2772,11 @@ export const createExecutor = => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); const name = connectionIdentifier(String(input.name)); // Typed (not StorageError) so the HTTP edge can answer 400 with the // reason instead of an opaque 500 — callers can act on it. @@ -3081,8 +3133,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -3109,9 +3162,10 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -3939,8 +3993,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); if (!isValidPattern(input.pattern)) { return yield* new StorageError({ message: `Invalid tool policy pattern: ${input.pattern}`, @@ -3986,8 +4041,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); if (input.pattern !== undefined && !isValidPattern(input.pattern)) { return yield* new StorageError({ message: `Invalid tool policy pattern: ${input.pattern}`, @@ -4011,10 +4067,16 @@ export const createExecutor = => - core.deleteMany("tool_policy", { - where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), - }); + const policiesRemove = ( + input: RemoveToolPolicyInput, + ): Effect.Effect => + guardOrgWrite(input.owner).pipe( + Effect.andThen( + core.deleteMany("tool_policy", { + where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), + }), + ), + ); const policiesResolve = ( address: ToolAddress, @@ -4532,6 +4594,7 @@ export const createExecutor = ownedKeys(owner), + guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 9d3cc9f587..8cae7f1173 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -69,6 +69,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 1e91467170..9116bae5ff 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -2,7 +2,7 @@ import type { Effect } from "effect"; import { Schema } from "effect"; import type { Connection } from "./connection"; -import type { UserActionableError } from "./errors"; +import type { OrgWriteDeniedError, UserActionableError } from "./errors"; import type { StorageFailure } from "./fuma-runtime"; import { type AuthTemplateSlug, @@ -271,12 +271,15 @@ export class OAuthSessionNotFoundError extends Schema.TaggedErrorClass Effect.Effect; + ) => Effect.Effect; /** Mint a client via RFC 7591 Dynamic Client Registration (no pre-shared * client id/secret) and persist it as an owner-scoped `oauth_client`. */ readonly registerDynamicClient: ( input: RegisterDynamicClientInput, - ) => Effect.Effect; + ) => Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + >; /** All registered clients visible to the caller (their org's shared clients + * their own user clients), as metadata-only summaries — never the secret. */ readonly listClients: () => Effect.Effect; @@ -288,10 +291,10 @@ export interface OAuthService { readonly removeClient: ( owner: Owner, slug: OAuthClientSlug, - ) => Effect.Effect; + ) => Effect.Effect; readonly start: ( input: OAuthStartInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly complete: ( input: OAuthCompleteInput, ) => Effect.Effect; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 742cf65adb..c7ebb65c6b 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -19,6 +19,7 @@ import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; import type { Connection } from "./connection"; +import type { OrgWriteDeniedError } from "./errors"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; import { StorageError } from "./fuma-runtime"; import { @@ -127,6 +128,10 @@ export interface OAuthServiceDeps { readonly owner: Owner; readonly subject: string; }; + /** Workspace-settings gate from the executor binding + * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the + * user-intent client/connect surfaces. */ + readonly guardOrgWrite: (owner: Owner) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -595,8 +600,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const createClient = ( input: CreateOAuthClientInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + yield* deps.guardOrgWrite(input.owner); yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), @@ -680,8 +686,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // the next token refresh, prompting a reconnect (graceful degradation; this // op never cascades into connections). // ----------------------------------------------------------------------- - const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect => + const removeClient = ( + owner: Owner, + slug: OAuthClientSlug, + ): Effect.Effect => Effect.gen(function* () { + yield* deps.guardOrgWrite(owner); yield* deps.fuma .use("oauth_client.delete", (db) => looseDb(db).deleteMany("oauth_client", { @@ -869,7 +879,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const registerDynamicClient = ( input: RegisterDynamicClientInput, - ): Effect.Effect => + ): Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + > => Effect.gen(function* () { const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with @@ -1044,8 +1057,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const start = ( input: OAuthStartInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + // Gate BEFORE any session row or upstream exchange: minting a Workspace + // connection (including a reconnect that would replace its credential) + // is a workspace-level change. + yield* deps.guardOrgWrite(input.owner); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts new file mode 100644 index 0000000000..c549ea4ad4 --- /dev/null +++ b/packages/core/sdk/src/org-writes.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { createExecutor } from "./executor"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +// --------------------------------------------------------------------------- +// `ExecutorConfig.orgWrites` — the workspace-settings gate. +// +// A `"denied"` binding (a plain member) may USE workspace resources — read +// them, execute tools over org connections — but every user-intent +// workspace-level mutation refuses with `OrgWriteDeniedError`: org-owned +// policies / connections / OAuth clients, and the tenant-shared integration +// catalog. `"allowed"` (admins, and hosts with no role model) behaves exactly +// as before. +// +// The fixtures build TWO executors over ONE test database: an admin +// (default `orgWrites`) that seeds the workspace, and a member +// (`orgWrites: "denied"`) that the assertions run against. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; + +const INTEG = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("deploy"), description: "deploy" }], + }), + invokeTool: ({ toolRow, credential }) => + Effect.succeed({ ran: toolRow.name, value: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Vercel", + config: {}, + }), + seedFresh: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("fresh"), + description: "Fresh", + config: {}, + }), + }), +}))(); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const admin = yield* createExecutor(config); + const member = yield* createExecutor({ ...config, orgWrites: "denied" }); + yield* Effect.addFinalizer(() => + admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), + ); + yield* admin.demo.seed(); + return { admin, member }; + }); + +const expectOrgWriteDenied = (effect: Effect.Effect) => + effect.pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); + }), + ); + +describe("orgWrites: denied", () => { + it.effect("refuses org tool policies but accepts user ones", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.policies.create({ owner: "org", pattern: "*", action: "block" }), + ); + const mine = yield* member.policies.create({ + owner: "user", + pattern: "*", + action: "require_approval", + }); + yield* expectOrgWriteDenied( + member.policies.update({ id: mine.id, owner: "org", action: "block" }), + ); + yield* expectOrgWriteDenied(member.policies.remove({ id: mine.id, owner: "org" })); + yield* member.policies.update({ id: mine.id, owner: "user", action: "approve" }); + yield* member.policies.remove({ id: mine.id, owner: "user" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org connections but accepts personal ones", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* expectOrgWriteDenied( + member.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }), + ); + const personal = yield* member.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }); + expect(personal.owner).toBe("user"); + + const shared = yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const ref = { owner: shared.owner, integration: shared.integration, name: shared.name }; + yield* expectOrgWriteDenied(member.connections.update(ref, { description: "renamed" })); + yield* expectOrgWriteDenied(member.connections.remove(ref)); + }).pipe(Effect.scoped), + ); + + it.effect("still USES the workspace: reads org rows and executes org-connection tools", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const visible = yield* member.connections.list({ owner: "org" }); + expect(visible.map((c) => String(c.name))).toContain("shared"); + const out = yield* member.execute(ToolAddress.make("tools.vercel.org.shared.deploy"), {}); + expect(out).toMatchObject({ ran: "deploy", value: "org-token" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses catalog mutations: new registration, update, health check, removal", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // A NEW slug is refused through the plugin ctx register path (the seam + // every add-integration flow funnels through)… + yield* expectOrgWriteDenied(member.demo.seedFresh()); + const fresh = yield* member.integrations.get(IntegrationSlug.make("fresh")); + expect(fresh).toBeNull(); + // …and so are the public catalog mutations. + yield* expectOrgWriteDenied(member.integrations.update(INTEG, { name: "Renamed" })); + yield* expectOrgWriteDenied(member.integrations.healthCheck.set(INTEG, null)); + yield* expectOrgWriteDenied(member.integrations.remove(INTEG)); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the register REPLACE arm open (config rewrites converge for members)", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // The admin already registered `vercel`; re-registering the same slug on + // the denied binding is the replace arm and must succeed — this is the + // path catalog rebuilds and legacy healing converge through. + yield* member.demo.seed(); + const row = yield* member.integrations.get(INTEG); + expect(row?.slug).toBe(INTEG); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org OAuth clients and org connect flows", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("shared-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }), + ); + yield* expectOrgWriteDenied( + member.oauth.removeClient("org", OAuthClientSlug.make("shared-app")), + ); + yield* expectOrgWriteDenied( + member.oauth.start({ + owner: "org", + clientOwner: "org", + client: OAuthClientSlug.make("shared-app"), + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("shared"), + }), + ); + // Personal clients stay open. + const slug = yield* member.oauth.createClient({ + owner: "user", + slug: OAuthClientSlug.make("my-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }); + expect(String(slug)).toBe("my-app"); + }).pipe(Effect.scoped), + ); +}); + +describe("orgWrites: default (allowed)", () => { + it.effect("admin bindings mutate workspace-level state as before", () => + Effect.gen(function* () { + const { admin } = yield* setup(); + const policy = yield* admin.policies.create({ + owner: "org", + pattern: "*", + action: "require_approval", + }); + expect(policy.owner).toBe("org"); + yield* admin.policies.remove({ id: policy.id, owner: "org" }); + yield* admin.integrations.update(INTEG, { name: "Vercel (renamed)" }); + const row = yield* admin.integrations.get(INTEG); + expect(row?.name).toBe("Vercel (renamed)"); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 6d4dbc23b2..7a7fed21b5 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -47,6 +47,7 @@ import type { IntegrationNotFoundError, IntegrationRemovalNotAllowedError, InvalidConnectionInputError, + OrgWriteDeniedError, } from "./errors"; import type { OAuthService } from "./oauth-client"; import type { CredentialProvider, ProviderEntry } from "./provider"; @@ -162,8 +163,12 @@ export interface PluginCtx { readonly core: { readonly integrations: { - /** Register / replace this plugin's integration in the catalog. */ - readonly register: (input: RegisterIntegrationInput) => Effect.Effect; + /** Register / replace this plugin's integration in the catalog. A NEW + * row is a workspace-level change gated by the executor's `orgWrites` + * binding; replacing an existing row stays open at every role. */ + readonly register: ( + input: RegisterIntegrationInput, + ) => Effect.Effect; readonly update: ( slug: IntegrationSlug, patch: { @@ -171,21 +176,24 @@ export interface PluginCtx { readonly description?: string; readonly config?: IntegrationConfig; }, - ) => Effect.Effect; + ) => Effect.Effect; readonly list: () => Effect.Effect; readonly get: ( slug: IntegrationSlug, ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; /** Declare (or clear, with null) the integration's health check. Core * owns this storage; plugins call it e.g. to install a zero-config * default probe at registration time. */ readonly setHealthCheck: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; readonly detect: ( url: string, ) => Effect.Effect; @@ -194,9 +202,15 @@ export interface PluginCtx { }; readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; }; }; @@ -210,6 +224,7 @@ export interface PluginCtx { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -221,10 +236,10 @@ export interface PluginCtx { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 615c7c6c57..85f8b89630 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -59,6 +59,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionNotFoundError, InvalidConnectionInputError, CredentialProviderNotRegisteredError, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index 9ad7945ff2..539bbeafe2 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -123,6 +123,10 @@ export type TestConfigOptions["onIntegrationChange"]; + /** Workspace-settings permission for the test binding (see + * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts + * with no role model. */ + readonly orgWrites?: ExecutorConfig["orgWrites"]; }; export const makeTestConfig = ( @@ -162,6 +166,7 @@ export const makeTestConfig = ; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 363038ef5c..d5fced917a 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -1,6 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; +import { + InternalError, + IntegrationAlreadyExistsError, + OrgWriteDeniedError, +} from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types"; @@ -87,6 +91,7 @@ const GraphqlErrors = [ IntrospectionError, ExtractionError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; export const GraphqlGroup = HttpApiGroup.make("graphql") diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 6e162d29df..6ffa353c77 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -19,6 +19,7 @@ import { type HealthCheckResult, type IntegrationConfig, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -1020,7 +1021,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { const configureAuthMethods = ( slug: string, input: GraphqlConfigureAuthInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d6841..0b90f3853a 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,6 +4,7 @@ import { IntegrationSlug, InternalError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; @@ -149,6 +150,7 @@ export const McpGroup = HttpApiGroup.make("mcp") McpConnectionError, McpToolDiscoveryError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ], }), ) @@ -156,7 +158,7 @@ export const McpGroup = HttpApiGroup.make("mcp") HttpApiEndpoint.delete("removeServer", "/mcp/servers/:slug", { params: SlugParams, success: RemoveServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -171,7 +173,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureServerPayload, success: ConfigureServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -179,6 +181,6 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureAuthPayload, success: ConfigureAuthResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857e..e6231b26e6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -23,6 +23,7 @@ import { type IntegrationConfig, type IntegrationRecord, type OAuthClientSummary, + type OrgWriteDeniedError, type Owner, type PluginCtx, type StaticToolSchema, @@ -1667,12 +1668,17 @@ export interface McpPluginExtension { input: McpServerInput, ) => Effect.Effect< { readonly slug: string }, - McpExtensionFailure | IntegrationAlreadyExistsError + McpExtensionFailure | IntegrationAlreadyExistsError | OrgWriteDeniedError >; - readonly removeServer: (slug: string) => Effect.Effect; + readonly removeServer: ( + slug: string, + ) => Effect.Effect; /** Ensure every stdio integration has its default connection (migrating any * legacy inline env into the secret store). Idempotent; safe to run at boot. */ - readonly reconcileStdioConnections: () => Effect.Effect; + readonly reconcileStdioConnections: () => Effect.Effect< + void, + McpExtensionFailure | OrgWriteDeniedError + >; readonly getServer: ( slug: string, ) => Effect.Effect< @@ -1682,9 +1688,9 @@ export interface McpPluginExtension { readonly configureServer: ( slug: string, config: McpIntegrationConfigType, - ) => Effect.Effect; + ) => Effect.Effect; readonly configureAuth: ( slug: string, input: McpConfigureAuthInput, - ) => Effect.Effect; + ) => Effect.Effect; } diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index d1ab562547..71efe17aed 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -7,6 +7,7 @@ import { IntegrationAlreadyExistsError, IntegrationNotFoundError, IntegrationSlug, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { @@ -33,6 +34,7 @@ const DomainErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); @@ -44,6 +46,7 @@ const UpdateSpecErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationNotFound, + OrgWriteDeniedError, ] as const; const SlugParams = { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 234b163f3c..f26d30ef53 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -18,6 +18,7 @@ import { type IntegrationConfig, type IntegrationPreset, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, } from "@executor-js/sdk/core"; @@ -165,6 +166,7 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationAlreadyExistsError + | OrgWriteDeniedError | StorageFailure >; /** Re-resolve the integration's spec (from its stored source URL, or the @@ -180,9 +182,10 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationNotFoundError + | OrgWriteDeniedError | StorageFailure >; - readonly removeSpec: (slug: string) => Effect.Effect; + readonly removeSpec: (slug: string) => Effect.Effect; readonly getIntegration: (slug: string) => Effect.Effect; /** Read the integration's full opaque config, including its * `authenticationTemplate`. Returns null when the integration is absent. */ @@ -194,7 +197,7 @@ export interface OpenApiPluginExtension { readonly configure: ( slug: string, input: OpenApiConfigureInput, - ) => Effect.Effect; + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -1140,7 +1143,7 @@ export const openApiPlugin = definePlugin< configure: ( slug: string, input: OpenApiConfigureInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));