From 3f5c9a03aff3a771b9ef383e2a4b568e84418c39 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sun, 31 May 2026 04:35:10 -0500 Subject: [PATCH] feat: enforce CLI and MCP token permissions ## Summary - adds a shared client-side API permission mapper and guarded API client for CLI and MCP calls - exposes a non-secret /api/auth/context endpoint for scoped token preflight - routes CLI and MCP task lookup helpers through the guarded client - preflights direct summary text fetches that bypass the JSON API helper - adds focused CLI and MCP token authorization coverage and documents the behavior Refs #336. ## Verification - pnpm --filter @veritas-kanban/shared build - pnpm --filter @veritas-kanban/cli typecheck - pnpm --filter @veritas-kanban/mcp build - pnpm --filter @veritas-kanban/server typecheck - focused CLI and MCP api-permissions tests - pnpm lint:budget - pnpm audit --prod --audit-level=high - pnpm build - GitHub Actions: Build, Lint & Type Check, Security Audit, Workspace Unit Tests --- cli/src/__tests__/api-permissions.test.ts | 65 ++++ cli/src/commands/summary.ts | 6 +- cli/src/utils/api.ts | 28 +- cli/src/utils/find.ts | 9 +- cli/vitest.config.ts | 9 + docs/CLI-GUIDE.md | 4 + docs/mcp/README.md | 4 +- mcp/src/__tests__/api-permissions.test.ts | 65 ++++ mcp/src/tools/summary.ts | 3 +- mcp/src/utils/api.ts | 28 +- mcp/src/utils/find.ts | 9 +- server/src/routes/auth.ts | 31 ++ shared/src/utils/api-client.ts | 25 ++ shared/src/utils/api-permissions.ts | 358 ++++++++++++++++++++++ shared/src/utils/index.ts | 1 + vitest.config.ts | 2 +- 16 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 cli/src/__tests__/api-permissions.test.ts create mode 100644 cli/vitest.config.ts create mode 100644 mcp/src/__tests__/api-permissions.test.ts create mode 100644 shared/src/utils/api-permissions.ts diff --git a/cli/src/__tests__/api-permissions.test.ts b/cli/src/__tests__/api-permissions.test.ts new file mode 100644 index 00000000..55651a99 --- /dev/null +++ b/cli/src/__tests__/api-permissions.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClientPermissionError, createGuardedApiClient } from '../utils/api.js'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('CLI API permission preflight', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('blocks mutating commands before calling the target endpoint when the token is read-only', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + role: 'read-only', + isLocalhost: false, + permissions: ['task:read'], + }) + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const api = createGuardedApiClient('http://vk.test', 'reader-key'); + + await expect( + api('/api/tasks', { + method: 'POST', + body: JSON.stringify({ title: 'blocked' }), + }) + ).rejects.toBeInstanceOf(ClientPermissionError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context'); + }); + + it('allows read commands when the token has the mapped read permission', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + role: 'read-only', + isLocalhost: false, + permissions: ['task:read'], + }) + ) + .mockResolvedValueOnce(jsonResponse([{ id: 'task_1', title: 'allowed' }])); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const api = createGuardedApiClient('http://vk.test', 'reader-key'); + const tasks = await api<{ id: string; title: string }[]>('/api/tasks'); + + expect(tasks).toEqual([{ id: 'task_1', title: 'allowed' }]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toBe('http://vk.test/api/tasks'); + }); +}); diff --git a/cli/src/commands/summary.ts b/cli/src/commands/summary.ts index 7e967ef8..8f4fb528 100644 --- a/cli/src/commands/summary.ts +++ b/cli/src/commands/summary.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import { api, API_BASE, buildApiHeaders } from '../utils/api.js'; +import { api, API_BASE, assertApiPermissionForRequest, buildApiHeaders } from '../utils/api.js'; export function registerSummaryCommands(program: Command): void { // Create summary parent command with subcommands @@ -88,6 +88,9 @@ export function registerSummaryCommands(program: Command): void { const standup = await api(`/api/summary/standup?date=${dateParam}&format=json`); console.log(JSON.stringify(standup, null, 2)); } else { + await assertApiPermissionForRequest( + `/api/summary/standup?date=${dateParam}&format=${format}` + ); // Fetch markdown or text directly const res = await fetch( `${API_BASE}/api/summary/standup?date=${dateParam}&format=${format}`, @@ -121,6 +124,7 @@ export function registerSummaryCommands(program: Command): void { const recent = await api(`/api/summary/recent?hours=${options.hours}`); console.log(JSON.stringify(recent, null, 2)); } else { + await assertApiPermissionForRequest(`/api/summary/memory?hours=${options.hours}`); const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`, { headers: buildApiHeaders({ accept: 'text/markdown, text/plain, application/json' }), }); diff --git a/cli/src/utils/api.ts b/cli/src/utils/api.ts index d9e001f8..a0530b13 100644 --- a/cli/src/utils/api.ts +++ b/cli/src/utils/api.ts @@ -1,2 +1,26 @@ -// Re-export shared API client -export { api, createApiClient, API_BASE, buildApiHeaders } from '@veritas-kanban/shared'; +// Re-export shared API helpers with CLI permission preflight enabled. +import { + API_BASE, + createApiClient, + createApiPermissionGuard, + createGuardedApiClient, + type ClientAuthContext, +} from '@veritas-kanban/shared'; + +export { + API_BASE, + ClientPermissionError, + buildApiHeaders, + createApiClient, + createGuardedApiClient, + getApiPermissionRequirement, + type ClientAuthContext, + type ClientAuthPermission, +} from '@veritas-kanban/shared'; + +export const api = createGuardedApiClient(API_BASE); + +const contextApi = createApiClient(API_BASE); +export const assertApiPermissionForRequest = createApiPermissionGuard(() => + contextApi('/api/auth/context') +); diff --git a/cli/src/utils/find.ts b/cli/src/utils/find.ts index cba20f69..47c92e69 100644 --- a/cli/src/utils/find.ts +++ b/cli/src/utils/find.ts @@ -1,2 +1,7 @@ -// Re-export shared findTask -export { findTask } from '@veritas-kanban/shared'; +import { findTask as findTaskWithClient } from '@veritas-kanban/shared'; +import type { Task } from './types.js'; +import { api } from './api.js'; + +export function findTask(id: string): Promise { + return findTaskWithClient(id, api); +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 00000000..64fc5527 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + globals: true, + }, +}); diff --git a/docs/CLI-GUIDE.md b/docs/CLI-GUIDE.md index e890dad9..5859c5cf 100644 --- a/docs/CLI-GUIDE.md +++ b/docs/CLI-GUIDE.md @@ -645,6 +645,10 @@ automation should use an `agent` role key; read-only dashboards and reporting scripts should use `read-only`. Reserve the admin key for setup, migration, backup/import, and policy operations. +The CLI preflights protected commands against `/api/auth/context` before it +calls the target endpoint. If `VK_API_KEY` lacks the mapped permission, the +command fails locally without sending the mutating request. + ### Read/Write Smoke Check Use this check after linking `vk` and exporting `VK_API_URL`/`VK_API_KEY`. It proves the CLI can both read from and write to the configured VK server. diff --git a/docs/mcp/README.md b/docs/mcp/README.md index d2fe3ad7..5436c802 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -711,7 +711,9 @@ Resources are useful for MCP clients that support resource browsing (e.g., Claud - API keys are passed via the `X-API-Key` header by the shared VK API client. The server also accepts `Authorization: Bearer ` for direct HTTP callers. - The MCP server reads `VK_API_KEY` from its environment and includes it in every HTTP request to VK. - Keys never appear in MCP tool inputs/outputs — they stay in the transport layer. -- v5 auth context classifies MCP calls with `actorType=agent`, `authMethod=api-key`, the configured token name, and role-derived permissions. Future MCP tools should request the narrowest server permission that matches the operation. +- v5 auth context classifies MCP calls with `actorType=agent`, `authMethod=api-key`, the configured token name, and role-derived permissions. +- MCP tool calls preflight protected HTTP requests against `/api/auth/context`. If the configured key lacks the mapped permission, the tool returns a permission error before sending the target write request. +- Future MCP tools should request the narrowest server permission that matches the operation. ### What the MCP Server Cannot Do diff --git a/mcp/src/__tests__/api-permissions.test.ts b/mcp/src/__tests__/api-permissions.test.ts new file mode 100644 index 00000000..8e375cb7 --- /dev/null +++ b/mcp/src/__tests__/api-permissions.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClientPermissionError, createGuardedApiClient } from '../utils/api.js'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('MCP API permission preflight', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('blocks write tools before calling the target endpoint when the token lacks write scope', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + role: 'agent', + isLocalhost: false, + permissions: ['task:read', 'agent:read'], + }) + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const api = createGuardedApiClient('http://vk.test', 'agent-key'); + + await expect( + api('/api/projects', { + method: 'POST', + body: JSON.stringify({ label: 'blocked' }), + }) + ).rejects.toBeInstanceOf(ClientPermissionError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('http://vk.test/api/auth/context'); + }); + + it('allows read tools when the token has the mapped read permission', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + role: 'agent', + isLocalhost: false, + permissions: ['report:read'], + }) + ) + .mockResolvedValueOnce(jsonResponse({ total: 0 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const api = createGuardedApiClient('http://vk.test', 'agent-key'); + const summary = await api<{ total: number }>('/api/summary'); + + expect(summary).toEqual({ total: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toBe('http://vk.test/api/summary'); + }); +}); diff --git a/mcp/src/tools/summary.ts b/mcp/src/tools/summary.ts index c3f9ea8a..2e523204 100644 --- a/mcp/src/tools/summary.ts +++ b/mcp/src/tools/summary.ts @@ -1,4 +1,4 @@ -import { api, API_BASE, buildApiHeaders } from '../utils/api.js'; +import { api, API_BASE, assertApiPermissionForRequest, buildApiHeaders } from '../utils/api.js'; export const summaryTools = [ { @@ -36,6 +36,7 @@ export async function handleSummaryTool(name: string, args: any): Promise { case 'get_memory_summary': { const hours = (args as { hours?: number })?.hours || 24; + await assertApiPermissionForRequest(`/api/summary/memory?hours=${hours}`); const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`, { headers: buildApiHeaders(), }); diff --git a/mcp/src/utils/api.ts b/mcp/src/utils/api.ts index d9e001f8..e84cfb79 100644 --- a/mcp/src/utils/api.ts +++ b/mcp/src/utils/api.ts @@ -1,2 +1,26 @@ -// Re-export shared API client -export { api, createApiClient, API_BASE, buildApiHeaders } from '@veritas-kanban/shared'; +// Re-export shared API helpers with MCP permission preflight enabled. +import { + API_BASE, + createApiClient, + createApiPermissionGuard, + createGuardedApiClient, + type ClientAuthContext, +} from '@veritas-kanban/shared'; + +export { + API_BASE, + ClientPermissionError, + buildApiHeaders, + createApiClient, + createGuardedApiClient, + getApiPermissionRequirement, + type ClientAuthContext, + type ClientAuthPermission, +} from '@veritas-kanban/shared'; + +export const api = createGuardedApiClient(API_BASE); + +const contextApi = createApiClient(API_BASE); +export const assertApiPermissionForRequest = createApiPermissionGuard(() => + contextApi('/api/auth/context') +); diff --git a/mcp/src/utils/find.ts b/mcp/src/utils/find.ts index cba20f69..47c92e69 100644 --- a/mcp/src/utils/find.ts +++ b/mcp/src/utils/find.ts @@ -1,2 +1,7 @@ -// Re-export shared findTask -export { findTask } from '@veritas-kanban/shared'; +import { findTask as findTaskWithClient } from '@veritas-kanban/shared'; +import type { Task } from './types.js'; +import { api } from './api.js'; + +export function findTask(id: string): Promise { + return findTaskWithClient(id, api); +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 182cd46d..7523152d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -154,6 +154,37 @@ router.get( }) ); +/** + * @openapi + * /api/auth/context: + * get: + * summary: Return current authenticated API context + * description: Returns non-secret auth metadata for CLI/MCP scoped token preflight. + * tags: [Auth] + * responses: + * 200: + * description: Current auth context + * 401: + * description: Authentication required + */ +router.get( + '/context', + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res: Response) => { + res.json({ + role: req.auth?.role, + keyName: req.auth?.keyName, + isLocalhost: req.auth?.isLocalhost, + userId: req.auth?.userId, + workspaceId: req.auth?.workspaceId, + actorType: req.auth?.actorType, + authMethod: req.auth?.authMethod, + tokenName: req.auth?.tokenName, + permissions: req.auth?.permissions ?? [], + }); + }) +); + /** * @openapi * /api/auth/setup: diff --git a/shared/src/utils/api-client.ts b/shared/src/utils/api-client.ts index 6e2bdcd4..6e588d89 100644 --- a/shared/src/utils/api-client.ts +++ b/shared/src/utils/api-client.ts @@ -3,6 +3,19 @@ */ import type { Task } from '../types/task.types.js'; +import { createApiPermissionGuard, type ClientAuthContext } from './api-permissions.js'; +export { + ClientPermissionError, + createApiPermissionGuard, + getApiPermissionRequirement, + hasClientPermission, + type ApiPermissionRequirement, + type ClientAuthActorType, + type ClientAuthContext, + type ClientAuthMethod, + type ClientAuthPermission, + type ClientAuthRole, +} from './api-permissions.js'; const DEFAULT_BASE = 'http://localhost:3001'; @@ -119,6 +132,18 @@ export function createApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_ }; } +export function createGuardedApiClient(baseUrl = DEFAULT_BASE, apiKey = getEnv('VK_API_KEY')) { + const rawApi = createApiClient(baseUrl, apiKey); + const assertPermission = createApiPermissionGuard(() => + rawApi('/api/auth/context') + ); + + return async function api(path: string, options?: RequestInit): Promise { + await assertPermission(path, options); + return rawApi(path, options); + }; +} + /** * Default API client using environment variable or localhost * Uses typeof check to avoid ReferenceError in browser environments diff --git a/shared/src/utils/api-permissions.ts b/shared/src/utils/api-permissions.ts new file mode 100644 index 00000000..be32f2f4 --- /dev/null +++ b/shared/src/utils/api-permissions.ts @@ -0,0 +1,358 @@ +export type ClientAuthRole = 'admin' | 'read-only' | 'agent'; +export type ClientAuthMethod = 'disabled' | 'session' | 'api-key' | 'localhost-bypass'; +export type ClientAuthActorType = 'user' | 'agent' | 'service' | 'localhost-bypass'; +export type ClientAuthPermission = + | '*' + | 'workspace:read' + | 'task:read' + | 'task:write' + | 'comment:write' + | 'workflow:read' + | 'workflow:write' + | 'workflow:execute' + | 'work_product:read' + | 'work_product:write' + | 'report:read' + | 'telemetry:read' + | 'telemetry:write' + | 'agent:read' + | 'agent:write' + | 'settings:read' + | 'settings:write' + | 'policy:read' + | 'policy:write' + | 'backup:read' + | 'backup:write' + | 'admin:manage'; + +export interface ClientAuthContext { + role: ClientAuthRole; + keyName?: string; + isLocalhost: boolean; + userId?: string; + workspaceId?: string; + actorType?: ClientAuthActorType; + authMethod?: ClientAuthMethod; + tokenName?: string; + permissions?: ClientAuthPermission[]; +} + +export interface ApiPermissionRequirement { + permissions: ClientAuthPermission[]; + path: string; + method: string; + public: boolean; +} + +export type ApiContextClient = (path: string, options?: RequestInit) => Promise; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +interface RoutePermissionConfig { + prefix: string; + read: ClientAuthPermission | ClientAuthPermission[]; + write?: ClientAuthPermission | ClientAuthPermission[]; + overrides?: { + methods?: string[]; + path: RegExp; + permissions: ClientAuthPermission | ClientAuthPermission[]; + }[]; +} + +function asPermissions( + permissions: ClientAuthPermission | ClientAuthPermission[] +): ClientAuthPermission[] { + return Array.isArray(permissions) ? permissions : [permissions]; +} + +function isSafeMethod(method: string): boolean { + return SAFE_METHODS.has(method.toUpperCase()); +} + +function normalizeApiPath(path: string): string { + const url = new URL(path, 'http://veritas.local'); + let normalized = url.pathname.replace(/\/+$/, '') || '/'; + + if (normalized === '/api/v1') { + normalized = '/api'; + } else if (normalized.startsWith('/api/v1/')) { + normalized = `/api${normalized.slice('/api/v1'.length)}`; + } + + return normalized; +} + +function routeRequirement( + config: RoutePermissionConfig, + path: string, + method: string +): ApiPermissionRequirement | null { + if (path !== config.prefix && !path.startsWith(`${config.prefix}/`)) { + return null; + } + + const relativePath = path.slice(config.prefix.length) || '/'; + const override = config.overrides?.find((candidate) => { + const methodMatches = + !candidate.methods || + candidate.methods.some((candidateMethod) => candidateMethod.toUpperCase() === method); + return methodMatches && candidate.path.test(relativePath); + }); + + const permissions = override + ? override.permissions + : isSafeMethod(method) + ? config.read + : (config.write ?? config.read); + + return { + permissions: asPermissions(permissions), + path, + method, + public: false, + }; +} + +const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [ + { + prefix: '/api/tasks', + read: 'task:read', + write: 'task:write', + overrides: [ + { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + path: /^\/[^/]+\/comments(?:\/.*)?$/, + permissions: 'comment:write', + }, + { + methods: ['GET', 'HEAD', 'OPTIONS'], + path: /^\/[^/]+\/work-products(?:\/.*)?$/, + permissions: 'work_product:read', + }, + { + methods: ['POST', 'PUT', 'PATCH', 'DELETE'], + path: /^\/[^/]+\/work-products(?:\/.*)?$/, + permissions: 'work_product:write', + }, + ], + }, + { prefix: '/api/backlog', read: 'task:read', write: 'task:write' }, + { prefix: '/api/observations', read: 'task:read' }, + { + prefix: '/api/config', + read: 'settings:read', + write: 'settings:write', + overrides: [ + { methods: ['POST'], path: /^\/repos\/validate\/?$/, permissions: 'settings:read' }, + ], + }, + { prefix: '/api/changes', read: 'task:read' }, + { prefix: '/api/chat', read: 'task:read', write: 'comment:write' }, + { prefix: '/api/agents/register', read: 'agent:read' }, + { prefix: '/api/agents/permissions', read: 'agent:read' }, + { prefix: '/api/agents', read: 'agent:read', write: 'task:write' }, + { prefix: '/api/diff', read: 'task:read' }, + { prefix: '/api/automation', read: 'task:read', write: 'task:write' }, + { prefix: '/api/summary', read: 'report:read' }, + { prefix: '/api/notifications', read: 'agent:read', write: 'comment:write' }, + { prefix: '/api/broadcasts', read: 'task:read', write: 'comment:write' }, + { prefix: '/api/templates', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/task-types', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/projects', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/sprints', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/activity', read: 'telemetry:read', write: 'admin:manage' }, + { prefix: '/api/github', read: 'task:read', write: 'task:write' }, + { prefix: '/api/preview', read: 'task:read' }, + { prefix: '/api/conflicts', read: 'task:read', write: 'task:write' }, + { prefix: '/api/telemetry', read: 'telemetry:read', write: 'telemetry:write' }, + { prefix: '/api/metrics', read: 'report:read' }, + { prefix: '/api/analytics', read: 'report:read' }, + { prefix: '/api/traces', read: 'telemetry:read', write: 'telemetry:write' }, + { prefix: '/api/drift', read: 'telemetry:read', write: 'telemetry:write' }, + { prefix: '/api/settings/transition-hooks', read: 'admin:manage', write: 'admin:manage' }, + { prefix: '/api/settings', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/agent/status', read: 'agent:read', write: 'telemetry:write' }, + { prefix: '/api/cost-prediction', read: 'report:read', write: 'task:write' }, + { prefix: '/api/deliverables', read: 'task:read', write: 'task:write' }, + { + prefix: '/api/reports', + read: 'report:read', + write: 'settings:write', + overrides: [{ methods: ['POST'], path: /^\/generate\/?$/, permissions: 'report:read' }], + }, + { prefix: '/api/doc-freshness', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/docs', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/errors', read: 'telemetry:read', write: 'telemetry:write' }, + { + prefix: '/api/search', + read: 'task:read', + write: 'settings:write', + overrides: [ + { methods: ['POST'], path: /^\/?$/, permissions: ['task:read', 'work_product:read'] }, + ], + }, + { prefix: '/api/work-products', read: 'work_product:read', write: 'work_product:write' }, + { prefix: '/api/hooks', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/shared-resources', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/status-history', read: 'telemetry:read', write: 'admin:manage' }, + { prefix: '/api/digest', read: 'report:read' }, + { prefix: '/api/audit', read: 'admin:manage', write: 'admin:manage' }, + { prefix: '/api/lessons', read: 'task:read' }, + { prefix: '/api/delegation', read: 'agent:read', write: 'admin:manage' }, + { + prefix: '/api/workflows', + read: 'workflow:read', + write: 'workflow:write', + overrides: [ + { methods: ['POST'], path: /^\/[^/]+\/runs\/?$/, permissions: 'workflow:execute' }, + { methods: ['POST'], path: /^\/runs\/[^/]+\/resume\/?$/, permissions: 'workflow:execute' }, + { + methods: ['POST'], + path: /^\/runs\/[^/]+\/steps\/[^/]+\/(approve|reject)\/?$/, + permissions: 'workflow:execute', + }, + ], + }, + { + prefix: '/api/tool-policies', + read: 'policy:read', + overrides: [ + { methods: ['POST'], path: /^\/evaluate\/?$/, permissions: ['policy:read', 'agent:read'] }, + ], + }, + { + prefix: '/api/policies', + read: 'policy:read', + overrides: [ + { methods: ['POST'], path: /^\/evaluate\/?$/, permissions: ['policy:read', 'agent:read'] }, + ], + }, + { prefix: '/api/integrations', read: 'settings:read', write: 'settings:write' }, + { prefix: '/api/transcripts', read: 'workspace:read', write: 'workflow:execute' }, + { + prefix: '/api/scoring', + read: 'report:read', + write: 'settings:write', + overrides: [{ methods: ['POST'], path: /^\/evaluate\/?$/, permissions: 'report:read' }], + }, + { prefix: '/api/system/health', read: 'workspace:read', write: 'admin:manage' }, + { prefix: '/api/decisions', read: 'task:read', write: 'task:write' }, + { prefix: '/api/feedback', read: 'report:read', write: 'comment:write' }, + { + prefix: '/api/prompt-registry', + read: 'settings:read', + write: 'settings:write', + overrides: [ + { + methods: ['POST'], + path: /^\/[^/]+\/render-preview\/?$/, + permissions: 'settings:read', + }, + { + methods: ['POST'], + path: /^\/[^/]+\/record-usage\/?$/, + permissions: 'telemetry:write', + }, + ], + }, + { prefix: '/api/sqlite', read: 'backup:read', write: 'backup:write' }, + { prefix: '/api/identity', read: 'workspace:read', write: 'admin:manage' }, +]; + +function isPublicApiPath(path: string): boolean { + return ( + path === '/health' || + path.startsWith('/health/') || + path === '/api/health' || + path === '/api/health/live' || + path === '/api/health/ready' || + path === '/api/auth/status' || + path === '/api/auth/setup' || + path === '/api/auth/login' || + path === '/api/auth/logout' || + path === '/api/auth/recover' || + path === '/api/auth/context' || + path.startsWith('/api/webhook/') + ); +} + +export function getApiPermissionRequirement( + path: string, + options: Pick = {} +): ApiPermissionRequirement { + const method = (options.method || 'GET').toUpperCase(); + const normalizedPath = normalizeApiPath(path); + + if (isPublicApiPath(normalizedPath)) { + return { permissions: [], path: normalizedPath, method, public: true }; + } + + for (const config of ROUTE_PERMISSIONS) { + const requirement = routeRequirement(config, normalizedPath, method); + if (requirement) return requirement; + } + + return { + permissions: ['admin:manage'], + path: normalizedPath, + method, + public: false, + }; +} + +export function hasClientPermission( + auth: Pick | undefined, + permission: ClientAuthPermission +): boolean { + if (!auth) return false; + if (auth.role === 'admin') return true; + + const permissions = auth.permissions ?? []; + return permissions.includes('*') || permissions.includes(permission); +} + +export class ClientPermissionError extends Error { + readonly code = 'CLIENT_PERMISSION_DENIED'; + readonly required: ClientAuthPermission[]; + readonly currentRole?: ClientAuthRole; + readonly currentPermissions: ClientAuthPermission[]; + readonly path: string; + readonly method: string; + + constructor(requirement: ApiPermissionRequirement, context: ClientAuthContext) { + const required = requirement.permissions.join(', '); + super( + `Token is not allowed to call ${requirement.method} ${requirement.path}. Required permission: ${required}. Current role: ${context.role}.` + ); + this.name = 'ClientPermissionError'; + this.required = requirement.permissions; + this.currentRole = context.role; + this.currentPermissions = context.permissions ?? []; + this.path = requirement.path; + this.method = requirement.method; + } +} + +export function createApiPermissionGuard(loadContext: () => Promise) { + let cachedContext: Promise | null = null; + + return async function assertApiPermissionForRequest( + path: string, + options: Pick = {} + ): Promise { + const requirement = getApiPermissionRequirement(path, options); + if (requirement.public) return null; + + cachedContext ??= loadContext(); + const context = await cachedContext; + const allowed = requirement.permissions.some((permission) => + hasClientPermission(context, permission) + ); + + if (!allowed) { + throw new ClientPermissionError(requirement, context); + } + + return context; + }; +} diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index eaffdf4d..891948cb 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -5,5 +5,6 @@ export * from './path.js'; export * from './format.js'; export * from './constants.js'; +export * from './api-permissions.js'; export * from './api-client.js'; export * from './agent-helpers.js'; diff --git a/vitest.config.ts b/vitest.config.ts index b7452359..bffb56c3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - projects: ['server', 'web', 'mcp'], + projects: ['server', 'web', 'mcp', 'cli'], }, });