From ff07c523627cf1c5f1307afc291d648a91e3fa15 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Wed, 19 Aug 2026 10:43:40 -0400 Subject: [PATCH] feat(auth): add read-only API keys (API_KEYS_READONLY) API_KEYS was a flat list where every token granted full read, write and delete over the REST API, so there was no safe credential to hand an external consumer of a hosted dataset. Add API_KEYS_READONLY: those tokens authenticate but are limited to GET/HEAD/OPTIONS on REST (403 otherwise) and to non-write MCP tools. The read-only tool set reuses the existing MCP_TOOLS_PROFILE category metadata (drop 'write') rather than a second classification. - API_KEYS keeps full access for backward compatibility; the server warns at startup when no read-only keys are configured - a token listed in both variables is read-only (deny wins) - an MCP session is pinned to the scope of the key that opened it, so a read-only token cannot reuse a read/write session - plugin tools have no category, so read-only sessions drop them Co-Authored-By: Claude Opus 5 --- .env.example | 6 ++ docs/api/rest-api.md | 3 +- docs/guide/configuration.md | 41 +++++++ docs/guide/installation.md | 13 +++ docs/var/02-installation-guide.md | 6 ++ docs/var/03-configuration-guide.md | 13 ++- src/api/auth.ts | 75 ++++++++++--- src/config/tools.ts | 14 +++ src/index.ts | 4 +- src/server/handlers.ts | 28 +++-- src/transport/http.ts | 45 ++++++-- tests/api/auth.test.ts | 6 +- tests/api/helpers.ts | 6 +- tests/api/readonly-keys.test.ts | 168 +++++++++++++++++++++++++++++ 14 files changed, 384 insertions(+), 44 deletions(-) create mode 100644 tests/api/readonly-keys.test.ts diff --git a/.env.example b/.env.example index f190a7a..bc6bb3c 100644 --- a/.env.example +++ b/.env.example @@ -134,8 +134,14 @@ MCP_TRANSPORT=stdio # API & MCP HTTP Auth (REST API and MCP endpoint when MCP_TRANSPORT=http) # ============================================================================ # Comma-separated list of valid API keys. Clients send via header or Authorization: Bearer +# These grant full access: read, write and delete. # API_KEYS=key1,key2 +# Read-only API keys. These authenticate, but are limited to GET on the REST API +# and to non-write MCP tools. Use these for external consumers of a dataset. +# A key listed in both variables is treated as read-only. +# API_KEYS_READONLY=readonly-key1,readonly-key2 + # Header for API key (default: authorization). Clients send Authorization: Bearer . Set to x-api-key to use that header instead. # API_KEY_HEADER=authorization diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 06cae94..67bb0af 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -31,7 +31,8 @@ The vCon MCP Server exposes a RESTful HTTP API alongside the MCP transport layer | Variable | Default | Description | |----------|---------|-------------| -| `API_KEYS` | (none) | Comma-separated list of valid API keys | +| `API_KEYS` | (none) | Comma-separated API keys with full read/write/delete access | +| `API_KEYS_READONLY` | (none) | Comma-separated read-only API keys — authenticate, but any non-GET request returns `403 Forbidden` | | `API_KEY_HEADER` | `authorization` | Header for API key; default expects `Authorization: Bearer `. Set to `x-api-key` to use that header instead. | | `API_AUTH_REQUIRED` | `true` | Set to `false` to disable authentication | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2e67ab5..82f6d1e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -152,6 +152,47 @@ to PostgREST exposed schemas. See [Multi-Supabase Isolation](multi-supabase-isolation.md) for complete patterns, the security trade-off, and provisioning scripts. +#### API Keys and Read-Only Access + +Both the REST API and the MCP HTTP endpoint authenticate with the same keys. + +```bash +# Full access: read, write, delete +API_KEYS=ops-key-1,ops-key-2 + +# Read-only: authenticates, but cannot mutate anything +API_KEYS_READONLY=partner-key-1,partner-key-2 + +# Header used for the key (default: authorization, i.e. Authorization: Bearer ) +API_KEY_HEADER=authorization + +# Require auth (default: true) +API_AUTH_REQUIRED=true +``` + +What a read-only key can do: + +| Surface | Allowed | Rejected | +|---------|---------|----------| +| REST | `GET`, `HEAD`, `OPTIONS` on any route | every `POST`, `PUT`, `PATCH`, `DELETE` → `403 Forbidden` | +| MCP | tools in the `read`, `schema`, `analytics`, `infra` categories | tools in the `write` category (not listed, and `tools/call` fails) | + +Notes: + +- Read-only keys are the credential to hand an external consumer of a hosted + dataset. `API_KEYS` tokens can delete the whole corpus in one request. +- A token listed in both variables is treated as read-only (deny wins). +- The read-only tool set is derived from the same categories as + `MCP_TOOLS_PROFILE` (see below) minus `write`, so `MCP_DISABLED_CATEGORIES` + and `MCP_DISABLED_TOOLS` still apply on top. +- An MCP session is pinned to the scope of the key that opened it; a request + carrying a session ID created under a different scope gets `403`. +- `API_KEYS` alone stays full access for backward compatibility. When no + read-only keys are configured, the server logs a warning at startup. +- REST reads are all `GET`, including search and analytics. The one read-ish + `POST` is `/database/analyze` (query plans), which read-only keys cannot use + over REST — the equivalent `analyze_query` MCP tool is available. + #### Tool Categories Control which tools are available in your deployment: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 7806e0e..af05344 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -88,6 +88,19 @@ SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your-anon-key-here ``` +If you expose the HTTP transport (REST API and MCP over HTTP), also set API keys: + +```env +# Full access: read, write, delete +API_KEYS=ops-key-1 +# Read-only: GET-only on REST, no write MCP tools. Give these to consumers. +API_KEYS_READONLY=partner-key-1 +``` + +`API_KEYS` tokens can delete the whole corpus, so hand out `API_KEYS_READONLY` +tokens to anyone who only needs to read. See +[Configuration → API Keys and Read-Only Access](configuration.md#api-keys-and-read-only-access). + **Getting Supabase Credentials:** 1. Go to [supabase.com](https://supabase.com) and sign in diff --git a/docs/var/02-installation-guide.md b/docs/var/02-installation-guide.md index 47626fc..64f16f0 100644 --- a/docs/var/02-installation-guide.md +++ b/docs/var/02-installation-guide.md @@ -87,12 +87,18 @@ cd vcon-mcp && npm install && npm run build -e MCP_TRANSPORT=http \ -e MCP_HTTP_HOST=0.0.0.0 \ -e API_KEYS='customer-key-1' \ + -e API_KEYS_READONLY='partner-readonly-key-1' \ public.ecr.aws/r4g1k2s3/vcon-dev/vcon-mcp:1.2.0 ``` 6. **Verify.** `curl http://localhost:3000/api/v1/health` returns `{"status":"ok"}` with `X-Version` and `X-Git-Commit` response headers. +`API_KEYS` grants full read/write/delete. Hand external consumers a key from +`API_KEYS_READONLY` instead: those keys get `403` on every non-GET REST request +and cannot call write MCP tools. See +[Configuration Guide → Authentication](./03-configuration-guide.md#authentication). + ### Transport choice | Transport | When to use | How to launch | diff --git a/docs/var/03-configuration-guide.md b/docs/var/03-configuration-guide.md index a38e98c..b82f385 100644 --- a/docs/var/03-configuration-guide.md +++ b/docs/var/03-configuration-guide.md @@ -51,14 +51,21 @@ API key auth covers both REST and MCP HTTP endpoints. | Variable | Default | Meaning | |---|---|---| | `API_AUTH_REQUIRED` | `true` | Require auth on REST + MCP HTTP | -| `API_KEYS` | — | Comma-separated valid keys | +| `API_KEYS` | — | Comma-separated keys with full read/write/delete access | +| `API_KEYS_READONLY` | — | Comma-separated read-only keys (GET-only on REST, no write tools on MCP) | | `API_KEY_HEADER` | `authorization` | Header to read key from | Default header `authorization` accepts `Authorization: Bearer `. Set `API_KEY_HEADER=x-api-key` to use a plain custom header. -**Misconfiguration trap:** `API_AUTH_REQUIRED=true` with empty `API_KEYS` -returns `503 Service Unavailable` until a key is set. +**Misconfiguration trap:** `API_AUTH_REQUIRED=true` with no keys in either +variable returns `503 Service Unavailable` until a key is set. + +**Read-only keys.** Give external consumers an `API_KEYS_READONLY` key. Those +keys get `403 Forbidden` on any non-GET REST request and cannot call `write` +category MCP tools. `API_KEYS` keys can delete the entire corpus, so never hand +one out. A key in both variables is read-only. Details: +[Configuration → API Keys and Read-Only Access](../guide/configuration.md#api-keys-and-read-only-access). ## Multi-tenant (RLS) diff --git a/src/api/auth.ts b/src/api/auth.ts index 1a33ab6..3edffd0 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -10,31 +10,55 @@ import type { Context, Next } from 'koa'; import { logWithContext } from '../observability/instrumentation.js'; export interface AuthConfig { - /** API keys that are allowed (comma-separated in env) */ + /** Full-access API keys (comma-separated in env API_KEYS) */ apiKeys: string[]; + /** Read-only API keys (comma-separated in env API_KEYS_READONLY) */ + readonlyKeys: string[]; /** Header name for API key (default: authorization, i.e. Authorization: Bearer ). */ headerName: string; /** Whether auth is required (default: true) */ required: boolean; } -/** - * Get auth configuration from environment - */ -export function getAuthConfig(): AuthConfig { - const apiKeysEnv = process.env.API_KEYS || ''; - const apiKeys = apiKeysEnv +function splitKeys(env: string | undefined): string[] { + return (env || '') .split(',') .map(k => k.trim()) .filter(k => k.length > 0); +} + +/** + * Get auth configuration from environment. + * + * API_KEYS keeps full read/write access (backward compatible). API_KEYS_READONLY + * tokens authenticate but may only read. A token listed in both is treated as + * read-only (deny wins). + */ +export function getAuthConfig(): AuthConfig { + const readonlyKeys = splitKeys(process.env.API_KEYS_READONLY); + const apiKeys = splitKeys(process.env.API_KEYS).filter(k => !readonlyKeys.includes(k)); return { apiKeys, + readonlyKeys, headerName: process.env.API_KEY_HEADER || 'authorization', required: process.env.API_AUTH_REQUIRED !== 'false', }; } +/** True if the token is a configured read-only key. */ +export function isReadonlyToken(config: AuthConfig, token: string): boolean { + return config.readonlyKeys.includes(token); +} + +/** All tokens that authenticate, whatever their scope. */ +function allKeys(config: AuthConfig): string[] { + return [...config.apiKeys, ...config.readonlyKeys]; +} + +/** HTTP methods a read-only token may use on the REST API. */ +const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + /** Lower-case header name for lookup (Node headers are lower-cased) */ function getHeader(req: IncomingMessage, name: string): string | undefined { const raw = req.headers[name.toLowerCase()]; @@ -57,7 +81,7 @@ export function getTokenFromRequest(req: IncomingMessage, headerName: string): s } export type ValidateHttpAuthResult = - | { ok: true } + | { ok: true; readonly: boolean } | { ok: false; statusCode: number; body: object; wwwAuth?: string }; /** @@ -69,9 +93,9 @@ export function validateHttpRequestAuth( config: AuthConfig ): ValidateHttpAuthResult { if (!config.required) { - return { ok: true }; + return { ok: true, readonly: false }; } - if (config.apiKeys.length === 0) { + if (allKeys(config).length === 0) { logWithContext('error', 'MCP auth required but no API keys configured - blocking request', { hint: 'Set API_KEYS, or set API_AUTH_REQUIRED=false', }); @@ -104,7 +128,7 @@ export function validateHttpRequestAuth( }, }; } - if (!config.apiKeys.includes(token)) { + if (!allKeys(config).includes(token)) { logWithContext('warn', 'Invalid MCP auth token attempted', { remote_address: req.socket?.remoteAddress, token_prefix: token.substring(0, 8) + '...', @@ -116,7 +140,7 @@ export function validateHttpRequestAuth( body: { error: 'Unauthorized', message: 'Invalid token' }, }; } - return { ok: true }; + return { ok: true, readonly: isReadonlyToken(config, token) }; } /** @@ -134,7 +158,7 @@ export function createAuthMiddleware(config?: Partial) { // Auth is required but no API keys are configured - this is a misconfiguration // Block requests with a clear error rather than silently allowing access - if (authConfig.apiKeys.length === 0) { + if (allKeys(authConfig).length === 0) { logWithContext('error', 'API auth required but no API keys configured - blocking request', { path: ctx.path, hint: 'Set API_KEYS environment variable, or set API_AUTH_REQUIRED=false to disable auth', @@ -171,8 +195,8 @@ export function createAuthMiddleware(config?: Partial) { return; } - // Check if API key is valid - if (!authConfig.apiKeys.includes(apiKey)) { + // Check if API key is valid (full-access or read-only) + if (!allKeys(authConfig).includes(apiKey)) { logWithContext('warn', 'Invalid API key attempted', { remote_address: ctx.ip, api_key_prefix: apiKey.substring(0, 8) + '...', @@ -187,8 +211,27 @@ export function createAuthMiddleware(config?: Partial) { return; } - // Store API key in state for downstream use + // Store API key + scope in state for downstream use ctx.state.apiKey = apiKey; + ctx.state.readonly = isReadonlyToken(authConfig, apiKey); + + // Read-only tokens may only read. Method-based, so every current and future + // mutating route is covered without a per-route allowlist. + if (ctx.state.readonly && !READ_METHODS.has(ctx.method.toUpperCase())) { + logWithContext('warn', 'Read-only API key attempted a write', { + remote_address: ctx.ip, + method: ctx.method, + path: ctx.path, + api_key_prefix: apiKey.substring(0, 8) + '...', + }); + ctx.status = 403; + ctx.body = { + error: 'Forbidden', + message: `Read-only API key cannot ${ctx.method} ${ctx.path}. Read-only keys are limited to GET requests.`, + }; + return; + } + await next(); }; } diff --git a/src/config/tools.ts b/src/config/tools.ts index 68c2160..a1d8ecc 100644 --- a/src/config/tools.ts +++ b/src/config/tools.ts @@ -141,6 +141,20 @@ export function loadToolsConfig(): ToolsConfig { }; } +/** + * Restrict a config to read-only tools, for read-only API keys. + * + * Reuses the existing category metadata: drop 'write' and keep the rest, which + * matches the REST rule (read-only keys get GETs) without a second per-tool + * classification. + */ +export function restrictToReadonly(config: ToolsConfig): ToolsConfig { + return { + ...config, + enabledCategories: config.enabledCategories.filter((c) => c !== 'write'), + }; +} + /** * Filter tools based on configuration */ diff --git a/src/index.ts b/src/index.ts index df7a0f2..6456b35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,9 +81,9 @@ async function main() { // A Server binds to one transport, so HTTP needs a fresh one per // session (stateful) / per request (stateless). - httpServerInstance = await startHttpServer(() => { + httpServerInstance = await startHttpServer(({ readonly }) => { const server = createServer(); - registerHandlers({ ...serverContext, server }); + registerHandlers({ ...serverContext, server }, { readonly }); return server; }, config); diff --git a/src/server/handlers.ts b/src/server/handlers.ts index 6432065..4c7f2ea 100644 --- a/src/server/handlers.ts +++ b/src/server/handlers.ts @@ -15,7 +15,13 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { randomUUID } from 'crypto'; -import { loadToolsConfig, filterEnabledTools, stripCategories, type ToolDefinition } from '../config/tools.js'; +import { + loadToolsConfig, + filterEnabledTools, + restrictToReadonly, + stripCategories, + type ToolDefinition, +} from '../config/tools.js'; import { logWithContext } from '../observability/instrumentation.js'; import { RequestContext } from '../hooks/plugin-interface.js'; import type { ToolHandlerContext } from '../tools/handlers/index.js'; @@ -35,8 +41,10 @@ import type { ServerContext } from './setup.js'; * Register all MCP request handlers * * @param context - Full server context (uses subset for tool handlers) + * @param options.readonly - Restrict this server to read-only tools (used when + * the HTTP session authenticated with a read-only API key). */ -export function registerHandlers(context: ServerContext): void { +export function registerHandlers(context: ServerContext, options: { readonly?: boolean } = {}): void { const { server, queries, pluginManager, handlerRegistry } = context; // Tool handler context - subset of ServerContext that handlers need @@ -51,7 +59,8 @@ export function registerHandlers(context: ServerContext): void { }; // Load tools configuration once at startup - const toolsConfig = loadToolsConfig(); + const baseToolsConfig = loadToolsConfig(); + const toolsConfig = options.readonly ? restrictToReadonly(baseToolsConfig) : baseToolsConfig; // List tools server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -79,8 +88,9 @@ export function registerHandlers(context: ServerContext): void { // Filter based on configuration const enabledTools = filterEnabledTools(allToolsWithCategories, toolsConfig); - // Get plugin tools (plugins don't have categories, always included if available) - const pluginTools = await pluginManager.getAdditionalTools(); + // Get plugin tools (plugins have no category, so a read-only session drops + // them rather than guess whether they mutate) + const pluginTools = options.readonly ? [] : await pluginManager.getAdditionalTools(); // Strip category field for MCP response (MCP doesn't need it) const toolsForResponse = [ @@ -128,7 +138,9 @@ export function registerHandlers(context: ServerContext): void { const isCategoryDisabled = !toolsConfig.enabledCategories.includes(toolDef.category); let errorMessage: string; - if (isExplicitlyDisabled) { + if (options.readonly && toolDef.category === 'write') { + errorMessage = `Tool '${name}' requires write access, but this session authenticated with a read-only API key.`; + } else if (isExplicitlyDisabled) { errorMessage = `Tool '${name}' is explicitly disabled via MCP_DISABLED_TOOLS configuration.`; } else if (isCategoryDisabled) { errorMessage = `Tool '${name}' is disabled. Category '${toolDef.category}' is not enabled in current configuration.`; @@ -148,8 +160,8 @@ export function registerHandlers(context: ServerContext): void { return handler.handle(args, handlerContext) as any; } - // Check if this is a plugin tool - const pluginTools = await pluginManager.getAdditionalTools(); + // Check if this is a plugin tool (never for read-only sessions, see above) + const pluginTools = options.readonly ? [] : await pluginManager.getAdditionalTools(); const pluginTool = pluginTools.find((t) => t.name === name); if (pluginTool) { diff --git a/src/transport/http.ts b/src/transport/http.ts index bf6e61e..6e1b9f7 100644 --- a/src/transport/http.ts +++ b/src/transport/http.ts @@ -70,10 +70,11 @@ export function createHttpTransport( * * @param createMcpServer - Factory returning a fresh MCP Server with handlers * registered. A Server binds to exactly one transport, so we need one per - * session (stateful) / per request (stateless). + * session (stateful) / per request (stateless). Receives the authenticated + * token's scope so read-only keys get a read-only tool set. */ export async function startHttpServer( - createMcpServer: () => Server, + createMcpServer: (options: { readonly: boolean }) => Server, config: HttpTransportConfig = {} ): Promise { // ?? not ||: port 0 is valid (bind any free port). @@ -84,27 +85,48 @@ export async function startHttpServer( // mode, where every request gets a throwaway transport. // ponytail: in-memory map, so stateful mode needs sticky routing across // replicas. Move to a shared store only if that becomes a real deployment. - const sessions = new Map(); + const sessions = new Map< + string, + { transport: StreamableHTTPServerTransport; readonly: boolean } + >(); async function handleMcpRequest( req: http.IncomingMessage, - res: http.ServerResponse + res: http.ServerResponse, + isReadonly: boolean ): Promise { const sessionId = req.headers['mcp-session-id'] as string | undefined; const existing = sessionId ? sessions.get(sessionId) : undefined; if (existing) { - return setupHttpMiddleware(req, res, existing); + // A session's tool set is fixed at creation, so a token may not join a + // session opened under a different scope (that would let a read-only key + // reuse a read/write session). + if (existing.readonly !== isReadonly) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Session was created with a different API key scope', + }, + id: null, + }) + ); + return; + } + return setupHttpMiddleware(req, res, existing.transport); } const transport = createHttpTransport(config, (id) => { - sessions.set(id, transport); + sessions.set(id, { transport, readonly: isReadonly }); }); transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); }; - await createMcpServer().connect(transport); + await createMcpServer({ readonly: isReadonly }).connect(transport); if (config.stateless) { // Single-use transport: tear it down once the response is done. @@ -135,7 +157,14 @@ export async function startHttpServer( auth_required: mcpAuthConfig.required, header: mcpAuthConfig.headerName, bearer_supported: true, + full_access_keys: mcpAuthConfig.apiKeys.length, + readonly_keys: mcpAuthConfig.readonlyKeys.length, }); + if (mcpAuthConfig.required && mcpAuthConfig.readonlyKeys.length === 0) { + logWithContext('warn', 'All configured API keys grant full read/write/delete access', { + hint: 'Set API_KEYS_READONLY for consumers that should only read', + }); + } // Create HTTP server that routes between REST API and MCP const httpServer = http.createServer((req, res) => { @@ -160,7 +189,7 @@ export async function startHttpServer( } // Fall through to MCP transport - handleMcpRequest(req, res).catch((error) => { + handleMcpRequest(req, res, authResult.readonly).catch((error) => { logWithContext('error', 'MCP request handling failed', { error_message: error instanceof Error ? error.message : String(error), error_stack: error instanceof Error ? error.stack : undefined, diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts index 4a8c238..adfbf24 100644 --- a/tests/api/auth.test.ts +++ b/tests/api/auth.test.ts @@ -47,19 +47,19 @@ describe('validateHttpRequestAuth', () => { it('returns ok when auth not required', () => { const config = { ...getAuthConfig(), required: false }; const req = mockReq({}); - expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true }); + expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true, readonly: false }); }); it('returns ok when Authorization Bearer token is valid (default)', () => { const config = getAuthConfig(); const req = mockReq({ authorization: 'Bearer key1' }); - expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true }); + expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true, readonly: false }); }); it('returns ok when custom header is valid (API_KEY_HEADER override)', () => { const config = { ...getAuthConfig(), headerName: 'x-api-key' }; const req = mockReq({ 'x-api-key': 'key2' }); - expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true }); + expect(validateHttpRequestAuth(req, config)).toEqual({ ok: true, readonly: false }); }); it('returns 401 when token missing and auth required', () => { diff --git a/tests/api/helpers.ts b/tests/api/helpers.ts index 5bfef4b..7ad5877 100644 --- a/tests/api/helpers.ts +++ b/tests/api/helpers.ts @@ -164,7 +164,7 @@ export interface TestAppContext { * Create a fully-mocked Koa app for testing REST routes. * Auth is disabled by default for test convenience. */ -export function createTestApp(): TestAppContext { +export function createTestApp(opts: { authRequired?: boolean } = {}): TestAppContext { const queries = createMockQueries(); const pluginManager = createMockPluginManager(); const vconService = createMockVConService(queries); @@ -182,8 +182,8 @@ export function createTestApp(): TestAppContext { dbSizeAnalyzer: dbSizeAnalyzer as any, }; - // Disable auth for tests - process.env.API_AUTH_REQUIRED = 'false'; + // Disable auth for tests unless the test is exercising auth itself + process.env.API_AUTH_REQUIRED = opts.authRequired ? 'true' : 'false'; const app = createRestApi(apiContext); diff --git a/tests/api/readonly-keys.test.ts b/tests/api/readonly-keys.test.ts new file mode 100644 index 0000000..4eb5d01 --- /dev/null +++ b/tests/api/readonly-keys.test.ts @@ -0,0 +1,168 @@ +/** + * Read-only API key tests. + * + * API_KEYS = full read/write/delete. API_KEYS_READONLY = authenticates, GET only + * on REST, read-only tool set over MCP. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { getAuthConfig, validateHttpRequestAuth } from '../../src/api/auth.js'; +import { createTestApp, sampleVCon, type TestAppContext } from './helpers.js'; + +vi.mock('../../src/observability/instrumentation.js', () => ({ + logWithContext: vi.fn(), + recordCounter: vi.fn(), +})); +vi.mock('../../src/observability/attributes.js', () => ({ + ATTR_SEARCH_TYPE: 'search.type', +})); + +const BASE = '/api/v1'; +const RW = 'rw-token'; +const RO = 'ro-token'; + +describe('read-only REST keys', () => { + let ctx: TestAppContext; + let savedAuthRequired: string | undefined; + + beforeEach(() => { + savedAuthRequired = process.env.API_AUTH_REQUIRED; + vi.stubEnv('API_KEYS', RW); + vi.stubEnv('API_KEYS_READONLY', RO); + ctx = createTestApp({ authRequired: true }); + }); + + afterEach(() => { + // createTestApp assigns API_AUTH_REQUIRED directly, so stubs don't cover it + if (savedAuthRequired === undefined) delete process.env.API_AUTH_REQUIRED; + else process.env.API_AUTH_REQUIRED = savedAuthRequired; + vi.unstubAllEnvs(); + }); + + it('allows GET /vcons with a read-only key', async () => { + await request(ctx.app.callback()) + .get(`${BASE}/vcons`) + .set('Authorization', `Bearer ${RO}`) + .expect(200); + }); + + it('rejects POST /vcons with a read-only key', async () => { + const res = await request(ctx.app.callback()) + .post(`${BASE}/vcons`) + .set('Authorization', `Bearer ${RO}`) + .send({ vcon: sampleVCon() }) + .expect(403); + + expect(res.body.error).toBe('Forbidden'); + expect(ctx.mocks.vconService.create).not.toHaveBeenCalled(); + }); + + it('rejects DELETE /vcons/{uuid} with a read-only key', async () => { + const res = await request(ctx.app.callback()) + .delete(`${BASE}/vcons/${sampleVCon().uuid}`) + .set('Authorization', `Bearer ${RO}`) + .expect(403); + + expect(res.body.error).toBe('Forbidden'); + expect(ctx.mocks.queries.deleteVCon).not.toHaveBeenCalled(); + }); + + it('still allows writes with a full-access key', async () => { + await request(ctx.app.callback()) + .post(`${BASE}/vcons`) + .set('Authorization', `Bearer ${RW}`) + .send({ vcon: sampleVCon() }) + .expect(201); + }); + + it('rejects unknown keys with 401, not 403', async () => { + await request(ctx.app.callback()) + .get(`${BASE}/vcons`) + .set('Authorization', 'Bearer nope') + .expect(401); + }); +}); + +describe('getAuthConfig scopes', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('treats a token listed in both lists as read-only', () => { + vi.stubEnv('API_KEYS', `${RW},shared`); + vi.stubEnv('API_KEYS_READONLY', 'shared'); + const config = getAuthConfig(); + expect(config.apiKeys).toEqual([RW]); + expect(config.readonlyKeys).toEqual(['shared']); + }); + + it('keeps legacy API_KEYS full-access when no read-only keys are set', () => { + vi.stubEnv('API_KEYS', RW); + vi.stubEnv('API_KEYS_READONLY', ''); + const req = { headers: { authorization: `Bearer ${RW}` }, socket: {} } as any; + expect(validateHttpRequestAuth(req, getAuthConfig())).toEqual({ ok: true, readonly: false }); + }); + + it('flags a read-only token on the MCP endpoint', () => { + vi.stubEnv('API_KEYS', RW); + vi.stubEnv('API_KEYS_READONLY', RO); + const req = { headers: { authorization: `Bearer ${RO}` }, socket: {} } as any; + expect(validateHttpRequestAuth(req, getAuthConfig())).toEqual({ ok: true, readonly: true }); + }); +}); + +describe('read-only MCP tool set', () => { + async function callTool(readonly: boolean, name: string, args: object = {}) { + const { registerHandlers } = await import('../../src/server/handlers.js'); + const server = new Server({ name: 't', version: '0' }, { capabilities: { tools: {} } }); + const handlers = new Map(); + // Capture the registered handlers instead of standing up a transport. + (server as any).setRequestHandler = (schema: any, handler: any) => { + handlers.set(schema === ListToolsRequestSchema ? 'list' : schema === CallToolRequestSchema ? 'call' : 'other', handler); + }; + + registerHandlers( + { + server, + queries: {} as any, + dbInspector: {} as any, + dbAnalytics: {} as any, + dbSizeAnalyzer: {} as any, + supabase: {}, + redis: null, + pluginManager: { getAdditionalTools: async () => [], getAdditionalResources: async () => [] } as any, + handlerRegistry: { get: () => undefined } as any, + vconService: {} as any, + }, + { readonly } + ); + + return { + list: () => handlers.get('list')({} as any), + call: () => handlers.get('call')({ params: { name, arguments: args } } as any), + }; + } + + it('rejects a write tool for a read-only session', async () => { + const { call } = await callTool(true, 'create_vcon'); + await expect(call()).rejects.toThrow(/read-only API key/); + }); + + it('does not list write tools for a read-only session', async () => { + const { list } = await callTool(true, 'create_vcon'); + const names = (await list()).tools.map((t: any) => t.name); + expect(names).not.toContain('create_vcon'); + expect(names).not.toContain('delete_vcon'); + expect(names).toContain('get_vcon'); + }); + + it('allows write tools for a full-access session', async () => { + const { list } = await callTool(false, 'create_vcon'); + const names = (await list()).tools.map((t: any) => t.name); + expect(names).toContain('create_vcon'); + }); +});