From 19562ff24c18ce563528d3de9896cb7700c3a065 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 15:19:49 +0000 Subject: [PATCH] feat(mcp-server): per-tool authorization policy evaluator (Prompt 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce required roles and business-scope constraints before tool execution (docs/mcp/spec.md §7). - src/tools/policy.ts: evaluateToolPolicy() checks required roles (any-of) and resolves the request's read scope as a subset of the caller's memberships, denying out-of-scope requests and empty scope (when required) with a deterministic AUTHORIZATION_ERROR; authorizeToolCall() convenience for a registered tool - unit tests for allow, role deny, out-of-scope deny, empty-scope deny, subset narrowing, any-of roles, and no-role-gate Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --- .../src/tools/__tests__/policy.test.ts | 118 ++++++++++++++++++ packages/mcp-server/src/tools/policy.ts | 82 ++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 packages/mcp-server/src/tools/__tests__/policy.test.ts create mode 100644 packages/mcp-server/src/tools/policy.ts diff --git a/packages/mcp-server/src/tools/__tests__/policy.test.ts b/packages/mcp-server/src/tools/__tests__/policy.test.ts new file mode 100644 index 000000000..9a032f27e --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/policy.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { buildAuthContext, type McpAuthContext } from '../../auth/identity.js'; +import type { AuthPrincipal } from '../../auth/token.js'; +import { authorizeToolCall, evaluateToolPolicy } from '../policy.js'; +import type { ToolAuthPolicy, ToolDefinition } from '../registry.js'; + +function authContext( + memberships: Array<{ businessId: string; roleId: string }>, + scopes: string[] = [], +): McpAuthContext { + const principal: AuthPrincipal = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes, + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, + }; + return buildAuthContext(principal, memberships); +} + +const M = (businessId: string) => ({ businessId, roleId: 'accountant' }); + +const scopedPolicy: ToolAuthPolicy = { + requiresBusinessScope: true, + dataClassification: 'business', +}; + +describe('evaluateToolPolicy — business scope', () => { + it('allows and defaults to all memberships when no scope is requested', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1'), M('b2')]), + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1', 'b2'] } }); + }); + + it('allows a caller-narrowed subset', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1'), M('b2'), M('b3')]), + requestedBusinessIds: ['b3', 'b1'], + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b3', 'b1'] } }); + }); + + it('denies a requested scope outside the memberships', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1')]), + requestedBusinessIds: ['b1', 'bX'], + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.code).toBe('AUTHORIZATION_ERROR'); + expect(decision.error.message).toMatch(/outside your authorized memberships/); + } + }); + + it('denies when the tool requires business scope but the caller has none', () => { + const decision = evaluateToolPolicy({ policy: scopedPolicy, auth: authContext([]) }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.message).toMatch(/No authorized business scope/); + } + }); +}); + +describe('evaluateToolPolicy — roles', () => { + const roleScopedPolicy: ToolAuthPolicy = { + requiredRoles: ['read:reports', 'admin'], + requiresBusinessScope: false, + dataClassification: 'business', + }; + + it('allows when the caller holds one of the required roles (any-of)', () => { + const decision = evaluateToolPolicy({ + policy: roleScopedPolicy, + auth: authContext([M('b1')], ['read:reports']), + }); + expect(decision.allowed).toBe(true); + }); + + it('denies when the caller holds none of the required roles', () => { + const decision = evaluateToolPolicy({ + policy: roleScopedPolicy, + auth: authContext([M('b1')], ['read:charges']), + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.message).toMatch(/missing a required role/); + } + }); + + it('applies no role gate when requiredRoles is omitted', () => { + const decision = evaluateToolPolicy({ + policy: { requiresBusinessScope: false, dataClassification: 'public' }, + auth: authContext([], []), + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: [] } }); + }); +}); + +describe('authorizeToolCall', () => { + it('evaluates the policy of a registered tool', () => { + const tool = { + name: 't', + description: 'd', + inputSchema: undefined as never, + policy: scopedPolicy, + handler: () => ({ content: [] }), + } as unknown as ToolDefinition; + + const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } }); + }); +}); diff --git a/packages/mcp-server/src/tools/policy.ts b/packages/mcp-server/src/tools/policy.ts new file mode 100644 index 000000000..bf3f28301 --- /dev/null +++ b/packages/mcp-server/src/tools/policy.ts @@ -0,0 +1,82 @@ +import { + resolveRequestedReadScope, + type AuthorizedReadScope, + type McpAuthContext, +} from '../auth/identity.js'; +import type { ToolAuthPolicy, ToolDefinition } from './registry.js'; + +/** + * Per-tool authorization policy evaluator (spec §7). + * + * Runs BEFORE a tool handler executes. It enforces the tool's required roles + * and business-scope constraints against the caller's auth context, and applies + * optional caller-provided scope narrowing — but only ever as a subset of the + * caller's authorized memberships. Any request outside those memberships is + * denied with a deterministic AUTHORIZATION_ERROR (never silently narrowed). + */ + +/** Deterministic authorization error payload (spec §10.2). */ +export interface AuthorizationError { + code: 'AUTHORIZATION_ERROR'; + message: string; +} + +export type PolicyDecision = + { allowed: true; readScope: AuthorizedReadScope } | { allowed: false; error: AuthorizationError }; + +function deny(message: string): PolicyDecision { + return { allowed: false, error: { code: 'AUTHORIZATION_ERROR', message } }; +} + +export interface EvaluatePolicyParams { + policy: ToolAuthPolicy; + auth: McpAuthContext; + /** Optional caller-provided business-scope narrowing (subset of memberships). */ + requestedBusinessIds?: readonly string[]; +} + +/** + * Evaluate a tool's policy for a caller. Returns the resolved read scope when + * allowed, or an AUTHORIZATION_ERROR when denied. + * + * Rules: + * - Required roles (any-of): the caller must hold at least one when the policy + * lists any. No list ⇒ no role gate. + * - Requested scope narrowing must be a subset of the caller's memberships; + * otherwise the request is denied (not silently dropped). + * - When the tool requires business scope, the resolved scope must be + * non-empty (a caller with no memberships cannot use it). + */ +export function evaluateToolPolicy(params: EvaluatePolicyParams): PolicyDecision { + const { policy, auth, requestedBusinessIds } = params; + + // 1. Role gate (any-of). + if (policy.requiredRoles && policy.requiredRoles.length > 0) { + const held = new Set(auth.roles); + if (!policy.requiredRoles.some(role => held.has(role))) { + return deny('Caller is missing a required role for this tool'); + } + } + + // 2. Resolve requested scope as a subset of authorized memberships. + const readScope = resolveRequestedReadScope(auth, requestedBusinessIds); + if (readScope === null) { + return deny('Requested business scope is outside your authorized memberships'); + } + + // 3. Business-scope requirement. + if (policy.requiresBusinessScope && readScope.businessIds.length === 0) { + return deny('No authorized business scope for this request'); + } + + return { allowed: true, readScope }; +} + +/** Convenience: evaluate the policy of a registered tool. */ +export function authorizeToolCall( + tool: ToolDefinition, + auth: McpAuthContext, + requestedBusinessIds?: readonly string[], +): PolicyDecision { + return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds }); +}