-
Notifications
You must be signed in to change notification settings - Fork 8
feat(mcp-server): authorization policy evaluator (MCP Prompt 11) #4012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: claude/mcp-prompt-10-tool-registry
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'] } }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+76
to
+82
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security Concern: Potential Tenant Isolation BypassIf a tool handler accepts a If a developer forgets to manually perform this check inside the tool's RecommendationImplement an automatic validation step in
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining — the tenant-isolation bypass is already prevented at the execution layer (added in the charges/report steps). This is covered by tests: Generated by Claude Code |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a test case to verify that
authorizeToolCallcorrectly denies requests when the input arguments contain abusinessIdoutside the resolved read scope.