mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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
This commit is contained in:
parent
b615052d4a
commit
3f5c9a03af
16 changed files with 635 additions and 12 deletions
65
cli/src/__tests__/api-permissions.test.ts
Normal file
65
cli/src/__tests__/api-permissions.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<unknown>(`/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<unknown>(`/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' }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ClientAuthContext>('/api/auth/context')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Task | null> {
|
||||
return findTaskWithClient(id, api);
|
||||
}
|
||||
|
|
|
|||
9
cli/vitest.config.ts
Normal file
9
cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <key>` 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
|
||||
|
||||
|
|
|
|||
65
mcp/src/__tests__/api-permissions.test.ts
Normal file
65
mcp/src/__tests__/api-permissions.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<any> {
|
|||
|
||||
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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ClientAuthContext>('/api/auth/context')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Task | null> {
|
||||
return findTaskWithClient(id, api);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<ClientAuthContext>('/api/auth/context')
|
||||
);
|
||||
|
||||
return async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
await assertPermission(path, options);
|
||||
return rawApi<T>(path, options);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default API client using environment variable or localhost
|
||||
* Uses typeof check to avoid ReferenceError in browser environments
|
||||
|
|
|
|||
358
shared/src/utils/api-permissions.ts
Normal file
358
shared/src/utils/api-permissions.ts
Normal file
|
|
@ -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 = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
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<RequestInit, 'method'> = {}
|
||||
): 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<ClientAuthContext, 'role' | 'permissions'> | 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<ClientAuthContext>) {
|
||||
let cachedContext: Promise<ClientAuthContext> | null = null;
|
||||
|
||||
return async function assertApiPermissionForRequest(
|
||||
path: string,
|
||||
options: Pick<RequestInit, 'method'> = {}
|
||||
): Promise<ClientAuthContext | null> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: ['server', 'web', 'mcp'],
|
||||
projects: ['server', 'web', 'mcp', 'cli'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue