Worktree api key scopes (#139)

* feat: add read/write scopes to API keys

API keys now require explicit scopes (e.g. transactions:read,
invoices:write) instead of having implicit full access. The create
dialog shows grouped checkboxes per domain with read/write split.
Legacy keys with null scopes default to read-only. MCP tools/list
is filtered by scope and tools/call rejects unauthorized calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Enhance API key scopes with suppliers and update descriptions for better clarity

* fix: drop function before recreating with changed return type

PostgreSQL cannot change return type via CREATE OR REPLACE.
Drop the existing function first to avoid SQLSTATE 42P13.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add new migration to drop and recreate function with scopes return type

The original migration was already applied, so a new migration is needed
to DROP the function first before recreating with the updated return type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add DROP FUNCTION to original migration, remove redundant fix migration

Preview branches replay all migrations from scratch. The original migration
must DROP the function before recreating it with a changed return type,
otherwise PostgreSQL rejects the CREATE OR REPLACE. The separate fix
migration is no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename migration to avoid duplicate version in schema_migrations

Version 20260325120000 is already recorded in the preview DB from a
prior failed apply. Renaming to 20260326130000 so Supabase treats it
as a new migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-26 13:30:26 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 5cb8c3c1e4
commit bb473e2c57
8 changed files with 408 additions and 27 deletions
+2 -2
View File
@@ -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) {
+10 -3
View File
@@ -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) {
+164 -11
View File
@@ -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<Scope, string> = {
'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<Set<Scope>>(new Set(ALL_SCOPES))
const [newKeyValue, setNewKeyValue] = useState('')
const [copied, setCopied] = useState(false)
const [revokingId, setRevokingId] = useState<string | null>(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() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{key.name}</p>
<div className="flex items-center gap-1 flex-wrap">
{(key.scopes ?? []).map((s) => (
<span
key={s}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{SCOPE_LABELS[s as Scope] ?? s}
</span>
))}
{(!key.scopes || key.scopes.length === 0) && (
<span className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] text-muted-foreground">
Enbart läs
</span>
)}
</div>
</div>
<div className="flex items-center gap-3 mt-1">
<code className="text-xs text-muted-foreground font-mono">
@@ -244,21 +338,80 @@ export function ApiKeysPanel() {
Ge nyckeln ett namn så du vet vad den används till.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label htmlFor="key-name">Namn</Label>
<Input
id="key-name"
placeholder="t.ex. Claude Desktop"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-name">Namn</Label>
<Input
id="key-name"
placeholder="t.ex. Claude Desktop"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<div className="space-y-2">
<Label>Behörigheter</Label>
<p className="text-xs text-muted-foreground">
Välj vad nyckeln ska ha åtkomst till.
</p>
<div className="space-y-3 pt-1">
{SCOPE_GROUPS.map((group) => (
<div key={group.domain} className="space-y-1.5">
<p className="text-sm font-medium">{group.label}</p>
<div className="space-y-1 pl-1">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={newKeyScopes.has(group.read)}
onCheckedChange={(checked) => {
setNewKeyScopes((prev) => {
const next = new Set(prev)
if (checked) {
next.add(group.read)
} else {
next.delete(group.read)
// Remove write too — write without read makes no sense
if (group.write) next.delete(group.write)
}
return next
})
}}
/>
<span className="text-xs text-muted-foreground">{group.readLabel}</span>
<span className="text-[10px] tabular-nums text-muted-foreground/60">{group.readTools} verktyg</span>
</label>
{group.write && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={newKeyScopes.has(group.write)}
onCheckedChange={(checked) => {
setNewKeyScopes((prev) => {
const next = new Set(prev)
if (checked) {
next.add(group.write!)
// Auto-check read when write is checked
next.add(group.read)
} else {
next.delete(group.write!)
}
return next
})
}}
/>
<span className="text-xs text-muted-foreground">{group.writeLabel}</span>
<span className="text-[10px] tabular-nums text-muted-foreground/60">{group.writeTools} verktyg</span>
</label>
)}
</div>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Skapa
</Button>
@@ -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<typeof import('@/lib/auth/api-keys')>()
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'
+18 -3
View File
@@ -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<Response> {
})
}
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<Response> {
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<Response> {
})),
})
)
}
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
@@ -2228,6 +2235,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
// 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<string, unknown> = {
+91 -3
View File
@@ -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<string, ApiKeyScope> = {
// 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)
}
+1
View File
@@ -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",
@@ -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;
$$;