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:
co-authored by
Claude Opus 4.6
parent
5cb8c3c1e4
commit
bb473e2c57
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+1
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user