diff --git a/.changeset/provider-neutral-authorization.md b/.changeset/provider-neutral-authorization.md new file mode 100644 index 000000000..8163f035d --- /dev/null +++ b/.changeset/provider-neutral-authorization.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Add an optional provider-neutral authorization seam for tool execution. Hosts can supply an `AuthorizationProvider` that receives Executor-bound tenant/subject identity and resolved tool metadata before credentials or plugin invocation; deny and provider failures fail closed, approval reuses the existing elicitation path, and nested tool execution re-enters the same authorization check. Existing hosts that do not configure a provider retain current behavior. diff --git a/packages/core/api/src/server/scoped-executor.authorization.test.ts b/packages/core/api/src/server/scoped-executor.authorization.test.ts new file mode 100644 index 000000000..d77fc1c7e --- /dev/null +++ b/packages/core/api/src/server/scoped-executor.authorization.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; + +import { + collectTables, + definePlugin, + tool, + ToolAddress, + type AuthorizationProvider, + type AuthorizationRequest, +} from "@executor-js/sdk"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { DbProvider } from "./executor-fuma-db"; +import { HostConfig, makeScopedExecutor, PluginsProvider } from "./scoped-executor"; + +describe("scoped executor authorization composition", () => { + it.effect("threads HostConfig authorizationProvider into the real scoped executor", () => + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + (db) => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const provider: AuthorizationProvider = { + authorize: (request) => + Effect.sync(() => { + seen.push(request); + return { + outcome: "allow" as const, + decisionId: "host-composition-allow", + policyRevision: "host-composition-v1", + reason: "test", + }; + }), + }; + const plugin = definePlugin(() => ({ + id: "host-authz-fixture" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "host-authz-fixture.control", + kind: "control" as const, + name: "Host authz fixture", + tools: [ + tool({ + name: "ping", + description: "ping", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("pong"), + }), + ], + }, + ], + }))(); + + const seams = Layer.mergeAll( + Layer.succeed(DbProvider)(db), + Layer.succeed(PluginsProvider)({ plugins: () => [plugin] }), + Layer.succeed(HostConfig)({ + allowLocalNetwork: false, + oauthCallbackPath: "/api/oauth/callback", + authorizationProvider: provider, + }), + ); + const executor = yield* makeScopedExecutor("subject-1", "tenant-1", "Tenant One").pipe( + Effect.provide(seams), + ); + + const result = yield* executor + .execute(ToolAddress.make("host-authz-fixture.control.ping"), {}) + .pipe(Effect.ensuring(executor.close().pipe(Effect.ignore))); + expect(result).toBe("pong"); + expect(seen).toHaveLength(1); + expect(String(seen[0]!.identity.tenant)).toBe("tenant-1"); + expect(String(seen[0]!.identity.subject)).toBe("subject-1"); + expect(seen[0]!.operation).toBe("tool.execute"); + expect(seen[0]!.tool.address).toBe("host-authz-fixture.control.ping"); + }), + (db) => Effect.promise(() => db.close()), + ), + ); +}); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index ea0e33ce6..dbe6d9855 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -6,7 +6,8 @@ // hosted HTTP client, build the `[userOrgScope, orgScope]` scope stack (P1), and // call `createExecutor({...})` with a byte-identical option shape. The ONLY real // differences were the DB source/lifetime, the plugin instances, and two host -// config scalars (`allowLocalNetwork`, `webBaseUrl`). +// config values (`allowLocalNetwork`, `webBaseUrl`, and optional horizontal +// authorization provider). // // `makeScopedExecutor` owns that common body. The per-host knobs are injected // through three Effect seams: @@ -17,8 +18,9 @@ // so both lifetimes are preserved by the Layer the host supplies. // - `PluginsProvider` — the plugin array. Cloud injects per-request WorkOS // credentials; self-host returns the plain plugin list. -// - `HostConfig` — `allowLocalNetwork` (drives the hosted HTTP client guard) -// and `webBaseUrl` (the core-tools elicitation base URL). +// - `HostConfig` — `allowLocalNetwork` (drives the hosted HTTP client guard), +// `webBaseUrl` (the core-tools elicitation base URL), and an optional +// provider-neutral authorization provider threaded to `createExecutor`. // // This is host-composition machinery: it lives in `@executor-js/api/server` // (the host surface), not in `@executor-js/sdk` (the plugin-author contract). @@ -52,7 +54,7 @@ import { import { DbProvider } from "./executor-fuma-db"; // --------------------------------------------------------------------------- -// HostConfig seam — the two host scalars that vary the `createExecutor` options. +// HostConfig seam — host values that vary the `createExecutor` options. // --------------------------------------------------------------------------- export interface HostConfigShape { @@ -97,6 +99,12 @@ export interface HostConfigShape { * Hosts that record product analytics supply it; omitted -> no observation. */ readonly onIntegrationChange?: ExecutorConfig["onIntegrationChange"]; + /** + * Optional horizontal authorization PEP provider. The shared host layer only + * threads this provider-neutral seam into `createExecutor`; concrete PDP + * adapters belong to the host/product composition, not @executor-js/api. + */ + readonly authorizationProvider?: ExecutorConfig["authorizationProvider"]; } export class HostConfig extends Context.Service()( @@ -284,6 +292,7 @@ export const makeScopedExecutor = < httpClientLayer, fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, + authorizationProvider: config.authorizationProvider, onElicitation: "accept-all", redirectUri, oauthCallbackStateOrgSlug: orgSlug, diff --git a/packages/core/sdk/src/authorization.test.ts b/packages/core/sdk/src/authorization.test.ts new file mode 100644 index 000000000..50522ff0e --- /dev/null +++ b/packages/core/sdk/src/authorization.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Predicate, Result, Schema } from "effect"; + +import type { + AuthorizationDecision, + AuthorizationProvider, + AuthorizationRequest, +} from "./authorization"; +import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { definePlugin, tool } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestExecutor } from "./testing"; + +const VERCEL = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const addr = (tool: string): ToolAddress => ToolAddress.make(`tools.${VERCEL}.org.${CONN}.${tool}`); + +const decision = ( + outcome: AuthorizationDecision["outcome"], + overrides?: Partial, +): AuthorizationDecision => ({ + outcome, + decisionId: overrides?.decisionId ?? "dec-1", + policyRevision: overrides?.policyRevision ?? "rev-1", + reason: overrides?.reason ?? `outcome=${outcome}`, + obligations: overrides?.obligations, +}); + +const recordingProvider = ( + outcomes: + | AuthorizationDecision["outcome"] + | ((req: AuthorizationRequest) => AuthorizationDecision), + seen: AuthorizationRequest[], +): AuthorizationProvider => ({ + authorize: (request) => + Effect.sync(() => { + seen.push(request); + return typeof outcomes === "function" ? outcomes(request) : decision(outcomes); + }), +}); + +class FailingAuthorizationProviderError extends Data.TaggedError( + "FailingAuthorizationProviderError", +)<{ + readonly message: string; +}> {} + +const failingProvider = (message: string): AuthorizationProvider => ({ + authorize: () => Effect.fail(new FailingAuthorizationProviderError({ message })), +}); + +/** Credential get that records whether resolution ran. */ +const gatedCredentialProvider = (resolved: { count: number }): CredentialProvider => { + const store = new Map([["item", "secret-value"]]); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => + Effect.sync(() => { + resolved.count++; + return store.get(String(id)) ?? null; + }), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + }; +}; + +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)), + }; +}; + +const makeAuthzPlugin = (options?: { + readonly credentialProvider?: CredentialProvider; + /** When set, only the named outer tool re-enters via `ctx.execute`. */ + readonly nested?: { readonly outer: string; readonly target: ToolAddress }; + readonly invokeCount?: { count: number }; +}) => { + const credentials = options?.credentialProvider ?? memoryProvider(); + const invokeCount = options?.invokeCount ?? { count: 0 }; + return definePlugin(() => ({ + id: "authz-fixture" as const, + storage: () => ({}), + credentialProviders: [credentials], + resolveTools: () => + Effect.succeed({ + tools: [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("delete"), description: "delete" }, + ], + }), + invokeTool: ({ toolRow, ctx, args }) => + Effect.gen(function* () { + invokeCount.count++; + if (options?.nested && toolRow.name === options.nested.outer) { + // Nested re-entry through the same core execute seam. + const nested = yield* ctx.execute(options.nested.target, args); + return { ran: toolRow.name, nested }; + } + return { ran: `${toolRow.integration}.${toolRow.name}` }; + }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: VERCEL, + description: "Vercel", + config: {}, + }), + }), + })); +}; + +const seedConnection = ( + executor: { + readonly ["authz-fixture"]: { readonly seed: () => Effect.Effect }; + readonly connections: { + readonly create: (input: { + readonly owner: "org"; + readonly name: ConnectionName; + readonly integration: IntegrationSlug; + readonly template: AuthTemplateSlug; + readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId }; + }) => Effect.Effect; + }; + }, + itemId = "item", +) => + Effect.gen(function* () { + yield* executor["authz-fixture"].seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: VERCEL, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make(itemId), + }, + }); + }); + +const recordingHandler = (calls: { count: number }): ElicitationHandler => + (() => { + calls.count++; + return Effect.succeed(ElicitationResponse.make({ action: "accept" })); + }) as ElicitationHandler; + +describe("AuthorizationProvider seam", () => { + it.effect("absent provider preserves current allow behavior", () => + Effect.gen(function* () { + const invokeCount = { count: 0 }; + const executor = yield* makeTestExecutor({ + plugins: [makeAuthzPlugin({ invokeCount })()] as const, + }); + yield* seedConnection(executor); + const result = yield* executor.execute(addr("deploy"), { x: 1 }); + expect(result).toEqual({ ran: "vercel.deploy" }); + expect(invokeCount.count).toBe(1); + }), + ); + + it.effect("identity is executor-bound, not caller-controlled via args", () => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const executor = yield* makeTestExecutor({ + tenant: "bound-tenant", + subject: "bound-subject", + authorizationProvider: recordingProvider("allow", seen), + plugins: [makeAuthzPlugin()()] as const, + }); + yield* seedConnection(executor); + + yield* executor.execute(addr("deploy"), { + tenant: "attacker-tenant", + subject: "attacker-subject", + identity: { tenant: "nope", subject: "nope" }, + }); + + expect(seen).toHaveLength(1); + expect(String(seen[0]!.identity.tenant)).toBe("bound-tenant"); + expect(String(seen[0]!.identity.subject)).toBe("bound-subject"); + expect(seen[0]!.operation).toBe("tool.execute"); + expect(seen[0]!.tool.integration).toBe("vercel"); + expect(seen[0]!.tool.owner).toBe("org"); + expect(seen[0]!.tool.connection).toBe("main"); + expect(seen[0]!.tool.plugin).toBe("authz-fixture"); + expect(seen[0]!.tool.name).toBe("deploy"); + expect(seen[0]!.args).toEqual({ + tenant: "attacker-tenant", + subject: "attacker-subject", + identity: { tenant: "nope", subject: "nope" }, + }); + }), + ); + + it.effect("deny fails closed before credential resolution", () => + Effect.gen(function* () { + const resolved = { count: 0 }; + const invokeCount = { count: 0 }; + const seen: AuthorizationRequest[] = []; + const executor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("deny", seen), + plugins: [ + makeAuthzPlugin({ + credentialProvider: gatedCredentialProvider(resolved), + invokeCount, + })(), + ] as const, + }); + yield* seedConnection(executor); + + // Seed may touch credential storage for connection create; reset after setup. + resolved.count = 0; + invokeCount.count = 0; + + const result = yield* Effect.result( + executor.execute(addr("deploy"), {}, { onElicitation: "accept-all" }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("AuthorizationDeniedError")(result.failure)).toBe(true); + expect(seen).toHaveLength(1); + expect(resolved.count).toBe(0); + expect(invokeCount.count).toBe(0); + }), + ); + + it.effect("provider allow cannot weaken an existing hard block", () => + Effect.gen(function* () { + const resolved = { count: 0 }; + const invokeCount = { count: 0 }; + const seen: AuthorizationRequest[] = []; + const executor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("allow", seen), + plugins: [ + makeAuthzPlugin({ + credentialProvider: gatedCredentialProvider(resolved), + invokeCount, + })(), + ] as const, + }); + yield* seedConnection(executor); + yield* executor.policies.create({ + owner: "org", + pattern: "vercel.org.main.deploy", + action: "block", + }); + resolved.count = 0; + invokeCount.count = 0; + + const result = yield* Effect.result( + executor.execute(addr("deploy"), {}, { onElicitation: "accept-all" }), + ); + expect(Result.isFailure(result)).toBe(true); + expect(seen).toHaveLength(0); + expect(resolved.count).toBe(0); + expect(invokeCount.count).toBe(0); + }), + ); + + it.effect("provider allow cannot weaken an existing approval requirement", () => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const calls = { count: 0 }; + const invokeCount = { count: 0 }; + const executor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("allow", seen), + plugins: [makeAuthzPlugin({ invokeCount })()] as const, + }); + yield* seedConnection(executor); + yield* executor.policies.create({ + owner: "org", + pattern: "vercel.org.main.deploy", + action: "require_approval", + }); + + const result = yield* executor.execute( + addr("deploy"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(seen).toHaveLength(1); + expect(seen[0]!.policy.action).toBe("require_approval"); + expect(calls.count).toBe(1); + expect(result).toEqual({ ran: "vercel.deploy" }); + expect(invokeCount.count).toBe(1); + }), + ); + + it.effect("require_approval feeds existing elicitation path", () => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const calls = { count: 0 }; + const invokeCount = { count: 0 }; + const executor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("require_approval", seen), + plugins: [makeAuthzPlugin({ invokeCount })()] as const, + }); + yield* seedConnection(executor); + + const result = yield* executor.execute( + addr("deploy"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(calls.count).toBe(1); + expect(result).toEqual({ ran: "vercel.deploy" }); + expect(invokeCount.count).toBe(1); + expect(seen).toHaveLength(1); + }), + ); + + it.effect("require_approval declined does not invoke and cannot bypass deny", () => + Effect.gen(function* () { + const invokeCount = { count: 0 }; + // Deny is absolute: a provider that denies never reaches elicitation. + const denyExecutor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("deny", []), + plugins: [makeAuthzPlugin({ invokeCount })()] as const, + }); + yield* seedConnection(denyExecutor); + const denied = yield* Effect.result( + denyExecutor.execute( + addr("deploy"), + {}, + { + onElicitation: () => Effect.succeed(ElicitationResponse.make({ action: "accept" })), + }, + ), + ); + expect(Result.isFailure(denied)).toBe(true); + if (!Result.isFailure(denied)) return; + expect(Predicate.isTagged("AuthorizationDeniedError")(denied.failure)).toBe(true); + expect(invokeCount.count).toBe(0); + + // require_approval + decline still fails closed via elicitation. + const approveCalls = { count: 0 }; + const reqExecutor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("require_approval", []), + plugins: [makeAuthzPlugin({ invokeCount })()] as const, + }); + yield* seedConnection(reqExecutor); + const declined = yield* Effect.result( + reqExecutor.execute( + addr("deploy"), + {}, + { + onElicitation: () => { + approveCalls.count++; + return Effect.succeed(ElicitationResponse.make({ action: "decline" })); + }, + }, + ), + ); + expect(Result.isFailure(declined)).toBe(true); + if (!Result.isFailure(declined)) return; + expect(Predicate.isTagged("ElicitationDeclinedError")(declined.failure)).toBe(true); + expect(approveCalls.count).toBe(1); + expect(invokeCount.count).toBe(0); + }), + ); + + it.effect("nested ctx.execute re-enters the authorization seam", () => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const outer = addr("deploy"); + const inner = addr("delete"); + const executor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("allow", seen), + plugins: [makeAuthzPlugin({ nested: { outer: "deploy", target: inner } })()] as const, + }); + yield* seedConnection(executor); + + const result = yield* executor.execute(outer, { nested: true }); + expect(result).toEqual({ + ran: "deploy", + nested: { ran: "vercel.delete" }, + }); + expect(seen.map((r) => r.tool.name)).toEqual(["deploy", "delete"]); + expect(seen.every((r) => r.operation === "tool.execute")).toBe(true); + }), + ); + + it.effect("provider failure fails closed without invoke", () => + Effect.gen(function* () { + const invokeCount = { count: 0 }; + const resolved = { count: 0 }; + const executor = yield* makeTestExecutor({ + authorizationProvider: failingProvider("upstream policy unavailable"), + plugins: [ + makeAuthzPlugin({ + credentialProvider: gatedCredentialProvider(resolved), + invokeCount, + })(), + ] as const, + }); + yield* seedConnection(executor); + resolved.count = 0; + invokeCount.count = 0; + + const result = yield* Effect.result(executor.execute(addr("deploy"), {})); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("AuthorizationProviderError")(result.failure)).toBe(true); + expect(resolved.count).toBe(0); + expect(invokeCount.count).toBe(0); + }), + ); + + it.effect("static tools also consult the provider", () => + Effect.gen(function* () { + const seen: AuthorizationRequest[] = []; + const staticPlugin = definePlugin(() => ({ + id: "static-authz" as const, + storage: () => ({}), + staticIntegrations: () => [ + { + id: "static-authz.ctl", + kind: "control" as const, + name: "Static Authz", + tools: [ + tool({ + name: "ping", + description: "ping", + inputSchema: Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({})), + ), + execute: () => Effect.succeed("pong"), + }), + ], + }, + ], + }))(); + + const allowExecutor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("allow", seen), + plugins: [staticPlugin] as const, + }); + const allowed = yield* allowExecutor.execute(ToolAddress.make("static-authz.ctl.ping"), {}); + expect(allowed).toBe("pong"); + expect(seen).toHaveLength(1); + expect(seen[0]!.tool.plugin).toBe("static-authz"); + expect(seen[0]!.tool.name).toBe("ping"); + + const denyExecutor = yield* makeTestExecutor({ + authorizationProvider: recordingProvider("deny", []), + plugins: [staticPlugin] as const, + }); + const denied = yield* Effect.result( + denyExecutor.execute(ToolAddress.make("static-authz.ctl.ping"), {}), + ); + expect(Result.isFailure(denied)).toBe(true); + if (!Result.isFailure(denied)) return; + expect(Predicate.isTagged("AuthorizationDeniedError")(denied.failure)).toBe(true); + }), + ); +}); diff --git a/packages/core/sdk/src/authorization.ts b/packages/core/sdk/src/authorization.ts new file mode 100644 index 000000000..31333ed53 --- /dev/null +++ b/packages/core/sdk/src/authorization.ts @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// AuthorizationProvider — optional universal authorization seam. +// +// When absent, execute keeps exact current behavior (coarse tool_policy block / +// require_approval + annotations). When present, core consults the provider +// after hard-block resolution and before credential resolution / plugin invoke. +// Nested `ctx.execute` re-enters the same path. +// +// Identity is the executor's bound (tenant, subject) — never caller-supplied +// args. Domain policy semantics and persistence live outside this contract. +// --------------------------------------------------------------------------- + +import type { Effect } from "effect"; + +import type { Subject, Tenant, ToolAddress } from "./ids"; +import type { EffectivePolicy } from "./policies"; + +/** The only operation this seam authorizes today. */ +export type AuthorizationOperation = "tool.execute"; + +/** Trusted executor identity. Built by core from the bound owner, not from args. */ +export interface AuthorizationIdentity { + readonly tenant: Tenant; + readonly subject: Subject | null; +} + +/** + * Tool address plus routing metadata the provider needs without re-parsing. + * Static tools still surface owner/connection as the executor projects them. + */ +export interface AuthorizationToolRef { + readonly address: ToolAddress; + readonly integration: string; + readonly owner: string; + readonly connection: string; + readonly plugin: string; + readonly name: string; +} + +export interface AuthorizationRequest { + readonly identity: AuthorizationIdentity; + readonly operation: AuthorizationOperation; + readonly tool: AuthorizationToolRef; + /** Raw invoke args. Caller-controlled; never a source of identity. */ + readonly args: unknown; + /** Coarse EffectivePolicy already resolved by core for this call. */ + readonly policy: EffectivePolicy; +} + +export type AuthorizationOutcome = "allow" | "deny" | "require_approval"; + +export interface AuthorizationDecision { + readonly outcome: AuthorizationOutcome; + readonly decisionId: string; + readonly policyRevision: string; + readonly reason: string; + /** Opaque host/provider metadata; core does not interpret obligations. */ + readonly obligations?: Readonly>; +} + +/** + * Host- or product-supplied authorizer. Failures (Effect failure channel) are + * fail-closed by core as `AuthorizationProviderError`. + */ +export interface AuthorizationProvider { + readonly authorize: ( + request: AuthorizationRequest, + ) => Effect.Effect; +} diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 7f28ab58e..2534eeb7f 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -80,6 +80,33 @@ export class ToolBlockedError extends Schema.TaggedErrorClass( } } +/** Tool invocation was rejected by an `AuthorizationProvider` with outcome + * `deny`. Fail-closed; not bypassable via invoke options. */ +export class AuthorizationDeniedError extends Schema.TaggedErrorClass()( + "AuthorizationDeniedError", + { + address: ToolAddress, + decisionId: Schema.String, + policyRevision: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Authorization denied (${this.decisionId}): ${this.reason}`; + } +} + +/** The configured `AuthorizationProvider` failed. Fail-closed: the call does + * not proceed to credentials or plugin invoke. */ +export class AuthorizationProviderError extends Schema.TaggedErrorClass()( + "AuthorizationProviderError", + { + address: ToolAddress, + message: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) {} + /** Tool row exists but its owning plugin isn't loaded in this executor config. */ export class PluginNotLoadedError extends Schema.TaggedErrorClass()( "PluginNotLoadedError", @@ -221,6 +248,8 @@ export type ExecuteError = | ToolNotFoundError | ToolInvocationError | ToolBlockedError + | AuthorizationDeniedError + | AuthorizationProviderError | PluginNotLoadedError | NoHandlerError | ConnectionNotFoundError diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 56cb2e299..31334aae0 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -63,8 +63,15 @@ import { type SaveArtifactInput, type SetArtifactPreviewInput, } from "./artifact"; +import type { + AuthorizationDecision, + AuthorizationProvider, + AuthorizationToolRef, +} from "./authorization"; import { ArtifactNotFoundError, + AuthorizationDeniedError, + AuthorizationProviderError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, @@ -609,6 +616,13 @@ export interface ExecutorConfig; /** * Fetch API implementation for dependencies that cannot consume `httpClientLayer`. @@ -4213,7 +4227,9 @@ export const createExecutor = { + if (forceApproval) return true; if (policy.action === "approve") return false; return policy.action === "require_approval" || annotations?.requiresApproval === true; }; @@ -4224,10 +4240,11 @@ export const createExecutor = Effect.gen(function* () { - if (!approvalRequired(annotations, policy)) return; - const policyForcesApproval = policy.action === "require_approval"; + if (!approvalRequired(annotations, policy, forceApproval)) return; + const policyForcesApproval = policy.action === "require_approval" || forceApproval; const message = annotations?.approvalDescription ? annotations.approvalDescription : policyForcesApproval && policy.pattern @@ -4246,6 +4263,60 @@ export const createExecutor = => { + const provider = config.authorizationProvider; + if (!provider) { + return Effect.succeed({ forceApproval: false, decision: null }); + } + return provider + .authorize({ + identity: { + tenant: ownerBinding.tenant, + subject: ownerBinding.subject, + }, + operation: "tool.execute", + tool, + args, + policy, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthorizationProviderError({ + address, + message: "Authorization provider failed", + cause, + }), + ), + Effect.flatMap((decision) => { + if (decision.outcome === "deny") { + return Effect.fail( + new AuthorizationDeniedError({ + address, + decisionId: decision.decisionId, + policyRevision: decision.policyRevision, + reason: decision.reason, + }), + ); + } + return Effect.succeed({ + forceApproval: decision.outcome === "require_approval", + decision, + }); + }), + ); + }; + // ------------------------------------------------------------------ // execute — the invoke path. // ------------------------------------------------------------------ @@ -4341,7 +4412,27 @@ export const createExecutor = ["onIntegrationChange"]; + readonly authorizationProvider?: ExecutorConfig["authorizationProvider"]; }; export const makeTestConfig = ( @@ -161,6 +162,7 @@ export const makeTestConfig =