feat(mcp-server): authorization policy evaluator (MCP Prompt 11)#4012
feat(mcp-server): authorization policy evaluator (MCP Prompt 11)#4012gilgardosh wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a per-tool authorization policy evaluator (evaluateToolPolicy and authorizeToolCall) along with corresponding unit tests to enforce role gates and business-scope constraints. The review feedback highlights a critical security concern regarding potential tenant isolation bypass if input arguments containing a businessId are not validated against the resolved authorized read scope. It is recommended to automatically validate these input arguments within authorizeToolCall and add a corresponding test case to verify this behavior.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function authorizeToolCall( | ||
| tool: ToolDefinition, | ||
| auth: McpAuthContext, | ||
| requestedBusinessIds?: readonly string[], | ||
| ): PolicyDecision { | ||
| return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds }); | ||
| } |
There was a problem hiding this comment.
Security Concern: Potential Tenant Isolation Bypass
If a tool handler accepts a businessId (or business_id) in its input arguments, there is currently no automatic validation ensuring that the requested input.businessId is within the resolved context.readScope.businessIds.
If a developer forgets to manually perform this check inside the tool's handler, a caller could pass an arbitrary businessId in the tool arguments and bypass tenant isolation, even if requestedBusinessIds was correctly narrowed or restricted at the policy level.
Recommendation
Implement an automatic validation step in authorizeToolCall that automatically rejects the request if the validated input contains a businessId or business_id field that is not present in the resolved readScope.businessIds (when requiresBusinessScope is true).
| export function authorizeToolCall( | |
| tool: ToolDefinition, | |
| auth: McpAuthContext, | |
| requestedBusinessIds?: readonly string[], | |
| ): PolicyDecision { | |
| return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds }); | |
| } | |
| export function authorizeToolCall( | |
| tool: ToolDefinition, | |
| auth: McpAuthContext, | |
| requestedBusinessIds?: readonly string[], | |
| inputArgs?: Record<string, unknown>, | |
| ): PolicyDecision { | |
| const decision = evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds }); | |
| if (!decision.allowed) { | |
| return decision; | |
| } | |
| if (tool.policy.requiresBusinessScope && inputArgs) { | |
| const targetBusinessId = inputArgs.businessId ?? inputArgs.business_id; | |
| if (typeof targetBusinessId === 'string' && !decision.readScope.businessIds.includes(targetBusinessId)) { | |
| return deny(`Requested businessId '${targetBusinessId}' is outside your authorized read scope`); | |
| } | |
| } | |
| return decision; | |
| } |
There was a problem hiding this comment.
Declining — the tenant-isolation bypass is already prevented at the execution layer (added in the charges/report steps). executeRegisteredTool derives the requested scope from a businessId/businessIds input and passes it to evaluateToolPolicy, which narrows via narrowReadScope and returns AUTHORIZATION_ERROR when any requested id is outside the caller's memberships. So a tool that reads input.businessId can never receive one outside scope — the call is denied before the handler runs.
This is covered by tests: charges "denies a requested business outside the memberships" and reports "denies a business outside the caller memberships". Adding the check to authorizeToolCall would duplicate logic that isn't on the execution path (the executor calls evaluateToolPolicy directly). Keeping enforcement in one place.
Generated by Claude Code
| const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']); | ||
| expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } }); | ||
| }); |
There was a problem hiding this comment.
Add a test case to verify that authorizeToolCall correctly denies requests when the input arguments contain a businessId outside the resolved read scope.
const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']);
expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } });
});
it('denies when the input arguments contain a businessId outside the resolved read scope', () => {
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'], { businessId: 'b2' });
expect(decision.allowed).toBe(false);
if (!decision.allowed) {
expect(decision.error.message).toMatch(/outside your authorized read scope/);
}
});cc1631c to
94a27f0
Compare
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo
d3ea230 to
19562ff
Compare
Summary
Implements Prompt 11 – Authorization policy evaluator (see
docs/mcp/spec.md§7). Evaluates each tool'spolicy against the caller's auth context before execution.
Changes
src/tools/policy.tsevaluateToolPolicy({ policy, auth, requestedBusinessIds }):requiresBusinessScope, the resolved scope must be non-empty.readScopewhen allowed, or a deterministicAUTHORIZATION_ERROR.authorizeToolCall(tool, auth, requestedBusinessIds)— convenience for a registered tool.Validation
yarn workspace @accounter/mcp-server test→ 128 passedyarn workspace @accounter/mcp-server lint/typecheck/build→ passNotes / open decisions
tools/callexecution path when the first real tool lands (Prompt 13).AUTHORIZATION_ERRORwill be folded into the unified error taxonomy in Prompt 17.🤖 Generated with Claude Code
Generated by Claude Code