diff --git a/app/api/mcp-oauth/token/route.ts b/app/api/mcp-oauth/token/route.ts index ca875b64..2498f75f 100644 --- a/app/api/mcp-oauth/token/route.ts +++ b/app/api/mcp-oauth/token/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes' -import { generateApiKey } from '@/lib/auth/api-keys' -import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys' /** * OAuth 2.0 Token Endpoint. @@ -111,6 +110,7 @@ export async function POST(request: Request) { key_hash: hash, key_prefix: prefix, name: 'MCP-klient (OAuth)', + scopes: ALL_SCOPES, }) if (insertError) { diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index 5d73f8a4..fb2c52d4 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateApiKey, hashApiKey } from '@/lib/auth/api-keys' +import { generateApiKey, hashApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys' +import type { ApiKeyScope } from '@/lib/auth/api-keys' /** * GET /api/settings/api-keys — List user's API keys (never exposes the key itself) @@ -39,13 +40,18 @@ export async function POST(request: Request) { } let name = 'Unnamed key' + let scopes: ApiKeyScope[] = DEFAULT_SCOPES try { const body = await request.json() if (body.name && typeof body.name === 'string') { name = body.name.slice(0, 100) } + const parsed = validateScopes(body.scopes) + if (parsed) { + scopes = parsed + } } catch { - // Empty body is fine, use default name + // Empty body is fine, use defaults } // Limit to 10 active keys per user @@ -71,8 +77,9 @@ export async function POST(request: Request) { key_hash: hash, key_prefix: prefix, name, + scopes, }) - .select('id, key_prefix, name, created_at') + .select('id, key_prefix, name, scopes, created_at') .single() if (error) { diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index 76736b0c..e95e74ce 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -13,9 +13,86 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { Checkbox } from '@/components/ui/checkbox' import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Copy, Check, Trash2, Key } from 'lucide-react' +const SCOPE_GROUPS = [ + { + domain: 'transactions', + label: 'Transaktioner', + read: 'transactions:read' as const, + readLabel: 'Läs — lista transaktioner, mallförslag, kategoriförslag', + readTools: 3, + write: 'transactions:write' as const, + writeLabel: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura', + writeTools: 3, + }, + { + domain: 'customers', + label: 'Kunder', + read: 'customers:read' as const, + readLabel: 'Läs — lista kunder', + readTools: 1, + write: 'customers:write' as const, + writeLabel: 'Skriv — skapa kunder', + writeTools: 1, + }, + { + domain: 'invoices', + label: 'Fakturor', + read: 'invoices:read' as const, + readLabel: 'Läs — lista fakturor', + readTools: 1, + write: 'invoices:write' as const, + writeLabel: 'Skriv — skapa, skicka, markera betald/skickad', + writeTools: 4, + }, + { + domain: 'suppliers', + label: 'Leverantörer', + read: 'suppliers:read' as const, + readLabel: 'Läs — lista leverantörer och leverantörsfakturor', + readTools: 2, + write: null, + writeLabel: null, + writeTools: 0, + }, + { + domain: 'reports', + label: 'Rapporter', + read: 'reports:read' as const, + readLabel: 'Läs — kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, kundreskontra, leverantörsreskontra, räkenskapsperioder, bankavstämning', + readTools: 11, + write: null, + writeLabel: null, + writeTools: 0, + }, +] as const + +type Scope = + | 'transactions:read' | 'transactions:write' + | 'customers:read' | 'customers:write' + | 'invoices:read' | 'invoices:write' + | 'suppliers:read' + | 'reports:read' + +const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => + g.write ? [g.read, g.write] : [g.read] +) + +/** Map scope key → display label for badges */ +const SCOPE_LABELS: Record = { + 'transactions:read': 'Transaktioner (läs)', + 'transactions:write': 'Transaktioner (skriv)', + 'customers:read': 'Kunder (läs)', + 'customers:write': 'Kunder (skriv)', + 'invoices:read': 'Fakturor (läs)', + 'invoices:write': 'Fakturor (skriv)', + 'suppliers:read': 'Leverantörer (läs)', + 'reports:read': 'Rapporter (läs)', +} + interface ApiKey { id: string key_prefix: string @@ -36,6 +113,7 @@ export function ApiKeysPanel() { const [showCreateDialog, setShowCreateDialog] = useState(false) const [showKeyDialog, setShowKeyDialog] = useState(false) const [newKeyName, setNewKeyName] = useState('') + const [newKeyScopes, setNewKeyScopes] = useState>(new Set(ALL_SCOPES)) const [newKeyValue, setNewKeyValue] = useState('') const [copied, setCopied] = useState(false) const [revokingId, setRevokingId] = useState(null) @@ -64,7 +142,7 @@ export function ApiKeysPanel() { const res = await fetch('/api/settings/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newKeyName || 'MCP-nyckel' }), + body: JSON.stringify({ name: newKeyName || 'MCP-nyckel', scopes: Array.from(newKeyScopes) }), }) const json = await res.json() @@ -77,6 +155,7 @@ export function ApiKeysPanel() { setShowCreateDialog(false) setShowKeyDialog(true) setNewKeyName('') + setNewKeyScopes(new Set(ALL_SCOPES)) fetchKeys() } catch { toast({ title: 'Fel', description: 'Kunde inte skapa nyckel', variant: 'destructive' }) @@ -161,6 +240,21 @@ export function ApiKeysPanel() {

{key.name}

+
+ {(key.scopes ?? []).map((s) => ( + + {SCOPE_LABELS[s as Scope] ?? s} + + ))} + {(!key.scopes || key.scopes.length === 0) && ( + + Enbart läs + + )} +
@@ -244,21 +338,80 @@ export function ApiKeysPanel() { Ge nyckeln ett namn så du vet vad den används till. -
- - setNewKeyName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} - /> +
+
+ + setNewKeyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ +

+ Välj vad nyckeln ska ha åtkomst till. +

+
+ {SCOPE_GROUPS.map((group) => ( +
+

{group.label}

+
+ + {group.write && ( + + )} +
+
+ ))} +
+
- diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index 8e262980..d0183f34 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -17,11 +17,18 @@ vi.mock('@/lib/supabase/server', () => ({ }), })) -vi.mock('@/lib/auth/api-keys', () => ({ - extractBearerToken: vi.fn().mockReturnValue('test-token'), - validateApiKey: vi.fn().mockResolvedValue({ userId: 'user-1' }), - createServiceClientNoCookies: vi.fn(), -})) +vi.mock('@/lib/auth/api-keys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + extractBearerToken: vi.fn().mockReturnValue('test-token'), + validateApiKey: vi.fn().mockResolvedValue({ + userId: 'user-1', + scopes: ['transactions:read', 'transactions:write', 'customers:read', 'customers:write', 'invoices:read', 'invoices:write', 'suppliers:read', 'reports:read'], + }), + createServiceClientNoCookies: vi.fn(), + } +}) vi.mock('@/lib/bookkeeping/category-mapping', () => ({ buildMappingResultFromCategory: vi.fn().mockReturnValue({ @@ -72,6 +79,64 @@ vi.mock('@/lib/reports/monthly-breakdown', () => ({ generateMonthlyBreakdown: vi.fn(), })) +vi.mock('@/lib/reports/balance-sheet', () => ({ + generateBalanceSheet: vi.fn(), +})) + +vi.mock('@/lib/reports/general-ledger', () => ({ + generateGeneralLedger: vi.fn(), +})) + +vi.mock('@/lib/reports/supplier-ledger', () => ({ + generateSupplierLedger: vi.fn(), +})) + +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + getReconciliationStatus: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoicePaymentJournalEntry: vi.fn(), + createInvoiceCashEntry: vi.fn(), + createInvoiceJournalEntry: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: vi.fn(), +})) + +vi.mock('@/lib/transactions/category-suggestions', () => ({ + getSuggestedCategories: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + upsertCounterpartyTemplate: vi.fn(), + findCounterpartyTemplatesBatch: vi.fn(), + formatCounterpartyName: vi.fn(), +})) + +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: vi.fn(), +})) + +vi.mock('@/lib/invoices/pdf-template', () => ({ + InvoicePDF: vi.fn(), +})) + +vi.mock('@/lib/email/service', () => ({ + getEmailService: vi.fn().mockReturnValue({ sendInvoice: vi.fn() }), +})) + +vi.mock('@/lib/email/invoice-templates', () => ({ + generateInvoiceEmailHtml: vi.fn(), + generateInvoiceEmailText: vi.fn(), + generateInvoiceEmailSubject: vi.fn(), +})) + +vi.mock('@/lib/core/documents/document-service', () => ({ + uploadDocument: vi.fn(), +})) + import { handleMcpRequest } from '../server' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' import { eventBus } from '@/lib/events/bus' diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 44356a54..471686d5 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3,6 +3,8 @@ import { extractBearerToken, validateApiKey, createServiceClientNoCookies, + hasScope, + TOOL_SCOPE_MAP, } from '@/lib/auth/api-keys' import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' @@ -2150,7 +2152,7 @@ export async function handleMcpRequest(request: Request): Promise { }) } - const { userId } = authResult + const { userId, scopes: keyScopes } = authResult const supabase = createServiceClientNoCookies() // ── Parse JSON-RPC ── @@ -2200,10 +2202,14 @@ export async function handleMcpRequest(request: Request): Promise { case 'ping': return NextResponse.json(jsonRpc(id ?? null, {})) - case 'tools/list': + case 'tools/list': { + const allowedTools = tools.filter((t) => { + const required = TOOL_SCOPE_MAP[t.name] + return !required || hasScope(keyScopes, required) + }) return NextResponse.json( jsonRpc(id ?? null, { - tools: tools.map((t) => ({ + tools: allowedTools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, @@ -2212,6 +2218,7 @@ export async function handleMcpRequest(request: Request): Promise { })), }) ) + } case 'tools/call': { const toolName = (params as Record)?.name as string @@ -2228,6 +2235,14 @@ export async function handleMcpRequest(request: Request): Promise { ) } + // Enforce scope + const requiredScope = TOOL_SCOPE_MAP[toolName] + if (requiredScope && !hasScope(keyScopes, requiredScope)) { + return NextResponse.json( + jsonRpcError(id ?? null, -32600, `Insufficient scope: this API key does not have the "${requiredScope}" scope`) + ) + } + try { const result = await tool.execute(toolArgs, userId, supabase) const response: Record = { diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 8cea3c7f..321b11ec 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -3,6 +3,83 @@ import { createClient } from '@supabase/supabase-js' const KEY_PREFIX = 'gnubok_sk_' +// ── API Key Scopes ────────────────────────────────────────── + +export const API_KEY_SCOPES = { + 'transactions:read': { label: 'Transaktioner — läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' }, + 'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, kvittomatchning, koppling mot faktura (3 verktyg)' }, + 'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' }, + 'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' }, + 'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' }, + 'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' }, + 'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' }, + 'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning (11 verktyg)' }, +} as const + +export type ApiKeyScope = keyof typeof API_KEY_SCOPES + +export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[] + +/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */ +export const DEFAULT_SCOPES: ApiKeyScope[] = [ + 'transactions:read', + 'customers:read', + 'invoices:read', + 'suppliers:read', + 'reports:read', +] + +/** Scope domain groups for UI rendering */ +export const SCOPE_GROUPS = [ + { domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const }, + { domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const }, + { domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const }, + { domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: null }, + { domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null }, +] as const + +/** Map MCP tool name → required scope */ +export const TOOL_SCOPE_MAP: Record = { + // Transactions + gnubok_list_uncategorized_transactions: 'transactions:read', + gnubok_categorize_transaction: 'transactions:write', + gnubok_receipt_matcher: 'transactions:write', + gnubok_get_counterparty_templates: 'transactions:read', + gnubok_suggest_categories: 'transactions:read', + gnubok_match_transaction_to_invoice: 'transactions:write', + // Customers + gnubok_list_customers: 'customers:read', + gnubok_create_customer: 'customers:write', + // Invoices + gnubok_list_invoices: 'invoices:read', + gnubok_create_invoice: 'invoices:write', + gnubok_send_invoice: 'invoices:write', + gnubok_mark_invoice_as_paid: 'invoices:write', + gnubok_mark_invoice_as_sent: 'invoices:write', + // Suppliers + gnubok_list_suppliers: 'suppliers:read', + gnubok_list_supplier_invoices: 'suppliers:read', + // Reports + gnubok_get_trial_balance: 'reports:read', + gnubok_get_vat_report: 'reports:read', + gnubok_get_kpi_report: 'reports:read', + gnubok_get_income_statement: 'reports:read', + gnubok_list_accounts: 'reports:read', + gnubok_get_balance_sheet: 'reports:read', + gnubok_get_general_ledger: 'reports:read', + gnubok_get_ar_ledger: 'reports:read', + gnubok_get_supplier_ledger: 'reports:read', + gnubok_list_fiscal_periods: 'reports:read', + gnubok_get_reconciliation_status: 'reports:read', +} + +export function validateScopes(scopes: unknown): ApiKeyScope[] | null { + if (scopes === null || scopes === undefined) return null + if (!Array.isArray(scopes)) return null + const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES) + return valid.length > 0 ? valid : null +} + /** * Create a Supabase service client that doesn't require cookies. * Used for API key validation (MCP, webhooks) where there's no browser session. @@ -35,11 +112,12 @@ export function extractBearerToken(request: Request): string | null { /** * Validate an API key and enforce rate limiting. * Uses the DB RPC for atomic check + increment. - * Returns the user_id on success, or an error with HTTP status. + * Returns the user_id and effective scopes on success, or an error with HTTP status. + * null scopes in DB → DEFAULT_SCOPES (read-only). */ export async function validateApiKey( key: string -): Promise<{ userId: string } | { error: string; status: number }> { +): Promise<{ userId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> { if (!key.startsWith(KEY_PREFIX)) { return { error: 'Invalid API key format', status: 401 } } @@ -61,5 +139,15 @@ export async function validateApiKey( return { error: 'Rate limit exceeded', status: 429 } } - return { userId: row.user_id } + return { + userId: row.user_id, + scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES, + } +} + +/** + * Check if a given scope is allowed by the key's scopes. + */ +export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean { + return keyScopes.includes(required) } diff --git a/package-lock.json b/package-lock.json index d3646a1e..172ccad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "erp-base", "version": "0.1.0", + "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/sdk": "^0.72.1", "@hookform/resolvers": "^5.2.2", diff --git a/supabase/migrations/20260326130000_api_key_scopes.sql b/supabase/migrations/20260326130000_api_key_scopes.sql new file mode 100644 index 00000000..050c8d43 --- /dev/null +++ b/supabase/migrations/20260326130000_api_key_scopes.sql @@ -0,0 +1,52 @@ +-- Drop and recreate validate_and_increment_api_key to add scopes to return type +-- (CREATE OR REPLACE cannot change return types in PostgreSQL) +DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text); + +CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text) +RETURNS TABLE(user_id uuid, rate_limited boolean, scopes text[]) +LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_user_id uuid; + v_rate_limit_rpm integer; + v_request_count integer; + v_window_start timestamptz; + v_scopes text[]; +BEGIN + -- Lock row for atomic update + SELECT ak.user_id, ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes + INTO v_user_id, v_rate_limit_rpm, v_request_count, v_window_start, v_scopes + FROM public.api_keys ak + WHERE ak.key_hash = p_key_hash AND ak.revoked_at IS NULL + FOR UPDATE; + + IF v_user_id IS NULL THEN + RETURN; + END IF; + + -- Reset window if expired (> 1 minute old) + IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN + UPDATE public.api_keys + SET request_count = 1, + rate_limit_window_start = now(), + last_used_at = now() + WHERE key_hash = p_key_hash; + + RETURN QUERY SELECT v_user_id, false, v_scopes; + RETURN; + END IF; + + -- Check rate limit + IF v_request_count >= v_rate_limit_rpm THEN + RETURN QUERY SELECT v_user_id, true, v_scopes; + RETURN; + END IF; + + -- Increment counter + UPDATE public.api_keys + SET request_count = request_count + 1, + last_used_at = now() + WHERE key_hash = p_key_hash; + + RETURN QUERY SELECT v_user_id, false, v_scopes; +END; +$$;