From b8605aabfc97a4b1650b789f618ed99629df0842 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 13:35:42 +0200 Subject: [PATCH] fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924) The API-key settings panel carried a hand-copied list of scope groups that had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write, companies:write and the three reconciliation scopes were missing, so a key minted in the dashboard could not call gnubok_create_company, the article tools, the seven reconciliation tools or the matching v1 endpoints. The per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES descriptions were hand-maintained and wrong (reports:read said 18, actual 30; bookkeeping:write said 11, actual 22). - Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS, TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so the client-side panel can bundle it. api-keys.ts re-exports everything, so existing imports are unchanged. - SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every scope (reconciliation has three), shared by the panel and the OAuth consent page. scopeKind() replaces the ad hoc suffix checks. - TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the hand-written counts are removed from the catalogue descriptions. - The panel renders groups and cards from the catalogue; i18n keys are derived from domain and scope id. The "(REST API)" heading suffix is computed from the counts instead of baked into the labels. - New unit test asserts every scope belongs to exactly one group and that counts equal TOOL_SCOPE_MAP occurrences. - New sv/en strings for the articles, companies:write and reconciliation scopes. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/api/mcp-oauth/authorize/route.ts | 18 +- components/settings/ApiKeysPanel.tsx | 210 +++--------- lib/auth/__tests__/scope-catalog.test.ts | 99 ++++++ lib/auth/api-keys.ts | 387 +-------------------- lib/auth/scope-catalog.ts | 420 +++++++++++++++++++++++ messages/en.json | 21 +- messages/sv.json | 21 +- 8 files changed, 625 insertions(+), 552 deletions(-) create mode 100644 lib/auth/__tests__/scope-catalog.test.ts create mode 100644 lib/auth/scope-catalog.ts diff --git a/DECISIONS.md b/DECISIONS.md index da3bcbf9..a5c202fc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1253,6 +1253,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR. [2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay. [2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published. +[2026-08-26] API-key scope pickers (settings panel + OAuth consent) render from one SCOPE_GROUPS list in lib/auth/scope-catalog.ts, with MCP tool counts derived from TOOL_SCOPE_MAP at module load: the panel's hand-copied group list had drifted to 24 of 30 scopes (no articles, companies:write or reconciliation, so dashboard-minted keys could not call those tools) and every per-scope count was stale. The catalogue is a separate pure module rather than api-keys.ts itself because the panel is a client component and api-keys.ts imports crypto and the service-role client; a unit test enforces one-group-per-scope so the next scope cannot silently vanish from the pickers. [2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive. [2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 89d8c40a..5ef70af7 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -13,6 +13,7 @@ import { API_KEY_SCOPES, DEFAULT_OAUTH_SCOPES, SCOPE_GROUPS, + scopeKind, validateScopes, type ApiKeyScope, } from '@/lib/auth/api-keys' @@ -838,13 +839,10 @@ function renderScopeCheckboxes( for (const group of SCOPE_GROUPS) { const rows: string[] = [] - if (group.read && ceiling.has(group.read)) { - rows.push(scopeRow(group.read, preChecked.has(group.read), 'read')) - renderedInGroups.add(group.read) - } - if (group.write && ceiling.has(group.write)) { - rows.push(scopeRow(group.write, preChecked.has(group.write), 'write')) - renderedInGroups.add(group.write) + for (const scope of group.scopes) { + if (!ceiling.has(scope)) continue + rows.push(scopeRow(scope, preChecked.has(scope), scopeKind(scope))) + renderedInGroups.add(scope) } if (rows.length > 0) { groups.push( @@ -853,11 +851,11 @@ function renderScopeCheckboxes( } } + // Defense in depth: the catalogue test guarantees full group coverage, so + // this bucket is empty unless a scope ships without a group. const remaining = ALL_SCOPES.filter(s => ceiling.has(s) && !renderedInGroups.has(s)) if (remaining.length > 0) { - const rows = remaining.map((s) => - scopeRow(s, preChecked.has(s), s.endsWith(':write') || s.endsWith(':manage') || s.endsWith(':approve') ? 'write' : 'read') - ) + const rows = remaining.map((s) => scopeRow(s, preChecked.has(s), scopeKind(s))) groups.push( `
Övriga
${rows.join('')}
` ) diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index f2271198..8e93f13d 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -31,133 +31,28 @@ import { cn, formatDateLong } from '@/lib/utils' import { copyToClipboard } from '@/lib/browser/copy-to-clipboard' import { getBranding } from '@/lib/branding/service' import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations' -import { STAGING_SCOPES } from '@/lib/auth/api-keys' -import type { ApiKeyScope } from '@/lib/auth/api-keys' +import { + ALL_SCOPES, + SCOPE_GROUPS, + STAGING_SCOPES, + TOOL_COUNT_BY_SCOPE, + scopeKind, + type ApiKeyScope, + type ScopeGroup, +} from '@/lib/auth/scope-catalog' const branding = getBranding() const connectorName = branding.appName.toLowerCase() -type ScopeEntry = { - scope: ApiKeyScope - labelKey: string - /** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */ - tools: number -} - -type ScopeGroup = { - domain: string - labelKey: string - read: ScopeEntry | null - write: ScopeEntry | null -} - -const SCOPE_GROUPS: ScopeGroup[] = [ - { - domain: 'transactions', - labelKey: 'group_transactions', - read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 }, - write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 }, - }, - { - domain: 'customers', - labelKey: 'group_customers', - read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 }, - write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 }, - }, - { - domain: 'invoices', - labelKey: 'group_invoices', - read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 }, - write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 }, - }, - { - domain: 'suppliers', - labelKey: 'group_suppliers', - read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 }, - write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 }, - }, - { - domain: 'reports', - labelKey: 'group_reports', - read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 }, - write: null, - }, - { - domain: 'bookkeeping', - labelKey: 'group_bookkeeping', - read: null, - write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 }, - }, - { - domain: 'payroll', - labelKey: 'group_payroll', - read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 }, - write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 }, - }, - { - domain: 'pending_operations', - labelKey: 'group_pending_operations', - read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 }, - write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 }, - }, - { - domain: 'agent', - labelKey: 'group_agent', - read: { scope: 'agent:read', labelKey: 'scope_agent_read', tools: 1 }, - write: { scope: 'agent:write', labelKey: 'scope_agent_write', tools: 2 }, - }, - { - domain: 'documents', - labelKey: 'group_documents', - read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 }, - write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 }, - }, - { - domain: 'companies', - labelKey: 'group_companies', - read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 }, - write: null, - }, - { - domain: 'events', - labelKey: 'group_events', - read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 }, - write: null, - }, - { - domain: 'webhooks', - labelKey: 'group_webhooks', - read: null, - write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 }, - }, - { - domain: 'operations', - labelKey: 'group_operations', - read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 }, - write: null, - }, - { - domain: 'compliance', - labelKey: 'group_compliance', - read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 3 }, - write: null, - }, - { - domain: 'skatteverket', - labelKey: 'group_skatteverket', - read: null, - write: { scope: 'skatteverket:write', labelKey: 'scope_skatteverket_write', tools: 2 }, - }, -] - type Scope = ApiKeyScope -const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => { - const out: Scope[] = [] - if (g.read) out.push(g.read.scope) - if (g.write) out.push(g.write.scope) - return out -}) +/** i18n key for a scope card: `scope__`. */ +const scopeLabelKey = (scope: Scope) => `scope_${scope.replace(':', '_')}` +/** i18n key for a group heading: `group_`. */ +const groupLabelKey = (group: ScopeGroup) => `group_${group.domain}` +/** A group with no MCP tool behind any of its scopes only gates REST endpoints. */ +const isRestOnlyGroup = (group: ScopeGroup) => + group.scopes.every((scope) => TOOL_COUNT_BY_SCOPE[scope] === 0) interface ApiKey { id: string @@ -223,16 +118,17 @@ function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: strin } function ScopeCard({ - entry, + scope, checked, onCheckedChange, }: { - entry: ScopeEntry + scope: Scope checked: boolean onCheckedChange: (checked: boolean) => void }) { const t = useTranslations('settings_api_keys') - const label = t(entry.labelKey) + const label = t(scopeLabelKey(scope)) + const tools = TOOL_COUNT_BY_SCOPE[scope] const sepIdx = label.indexOf(': ') const verb = sepIdx > 0 ? label.slice(0, sepIdx) : label const description = sepIdx > 0 ? label.slice(sepIdx + 2) : '' @@ -254,7 +150,7 @@ function ScopeCard({ /> {verb} - {entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')} + {tools > 0 ? t('tools_count', { count: tools }) : t('rest_badge')} {description && ( @@ -297,6 +193,24 @@ export function ApiKeysPanel() { const hasSodConflict = newKeyScopes.has('pending_operations:approve') && sodConflictScope !== null + // Elevated scopes (write/approve/signoff) imply the group's read scope: + // ticking one ticks read, and unticking read clears the whole group. + function toggleScope(group: ScopeGroup, scope: Scope, checked: boolean) { + setNewKeyScopes((prev) => { + const next = new Set(prev) + const readScope = group.scopes.find((s) => scopeKind(s) === 'read') + if (checked) { + next.add(scope) + if (readScope) next.add(readScope) + } else if (scope === readScope) { + for (const s of group.scopes) next.delete(s) + } else { + next.delete(scope) + } + return next + }) + } + const fetchKeys = useCallback(async () => { try { const res = await fetch('/api/settings/api-keys') @@ -638,44 +552,20 @@ export function ApiKeysPanel() {
{SCOPE_GROUPS.map((group) => (
-

{t(group.labelKey)}

+

+ {isRestOnlyGroup(group) + ? t('group_rest_only', { name: t(groupLabelKey(group)) }) + : t(groupLabelKey(group))} +

- {group.read && ( + {group.scopes.map((scope) => ( { - setNewKeyScopes((prev) => { - const next = new Set(prev) - if (checked) { - next.add(group.read!.scope) - } else { - next.delete(group.read!.scope) - if (group.write) next.delete(group.write.scope) - } - return next - }) - }} + key={scope} + scope={scope} + checked={newKeyScopes.has(scope)} + onCheckedChange={(checked) => toggleScope(group, scope, checked)} /> - )} - {group.write && ( - { - setNewKeyScopes((prev) => { - const next = new Set(prev) - if (checked) { - next.add(group.write!.scope) - if (group.read) next.add(group.read.scope) - } else { - next.delete(group.write!.scope) - } - return next - }) - }} - /> - )} + ))}
))} diff --git a/lib/auth/__tests__/scope-catalog.test.ts b/lib/auth/__tests__/scope-catalog.test.ts new file mode 100644 index 00000000..d3be56a9 --- /dev/null +++ b/lib/auth/__tests__/scope-catalog.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_SCOPES, + API_KEY_SCOPES, + SCOPE_GROUPS, + TOOL_COUNT_BY_SCOPE, + TOOL_SCOPE_MAP, + scopeKind, + type ApiKeyScope, +} from '../scope-catalog' +import * as apiKeys from '../api-keys' + +describe('SCOPE_GROUPS', () => { + it('covers every scope in API_KEY_SCOPES exactly once', () => { + const occurrences = new Map() + for (const group of SCOPE_GROUPS) { + for (const scope of group.scopes) { + occurrences.set(scope, (occurrences.get(scope) ?? 0) + 1) + } + } + const missing = ALL_SCOPES.filter((s) => !occurrences.has(s)) + const duplicated = [...occurrences].filter(([, n]) => n > 1).map(([s]) => s) + expect(missing).toEqual([]) + expect(duplicated).toEqual([]) + }) + + it('only references scopes that exist in the catalogue', () => { + for (const group of SCOPE_GROUPS) { + for (const scope of group.scopes) { + expect(scope in API_KEY_SCOPES).toBe(true) + } + } + }) + + it('has unique domains and lists the read scope first', () => { + const domains = SCOPE_GROUPS.map((g) => g.domain) + expect(new Set(domains).size).toBe(domains.length) + for (const group of SCOPE_GROUPS) { + const readIndex = group.scopes.findIndex((s) => scopeKind(s) === 'read') + if (readIndex !== -1) expect(readIndex).toBe(0) + } + }) +}) + +describe('TOOL_COUNT_BY_SCOPE', () => { + it('has an entry for every scope and none for anything else', () => { + expect(Object.keys(TOOL_COUNT_BY_SCOPE).sort()).toEqual([...ALL_SCOPES].sort()) + }) + + it('equals the number of TOOL_SCOPE_MAP entries mapped to each scope', () => { + for (const scope of ALL_SCOPES) { + const expected = Object.values(TOOL_SCOPE_MAP).filter((s) => s === scope).length + expect(TOOL_COUNT_BY_SCOPE[scope], scope).toBe(expected) + } + const total = Object.values(TOOL_COUNT_BY_SCOPE).reduce((a, b) => a + b, 0) + expect(total).toBe(Object.keys(TOOL_SCOPE_MAP).length) + }) + + it('only maps tools to scopes that exist', () => { + for (const [tool, scope] of Object.entries(TOOL_SCOPE_MAP)) { + expect(scope in API_KEY_SCOPES, tool).toBe(true) + } + }) +}) + +describe('API_KEY_SCOPES labels', () => { + it('carries no hand-written tool counts (they are derived)', () => { + for (const [scope, meta] of Object.entries(API_KEY_SCOPES)) { + expect(meta.description, scope).not.toMatch(/\(\d+ verktyg\)/) + expect(meta.label, scope).not.toMatch(/\(\d+ verktyg\)/) + } + }) + + it('formats every label as "Område: verb"', () => { + for (const meta of Object.values(API_KEY_SCOPES)) { + expect(meta.label).toMatch(/^[^:]+: .+$/) + } + }) +}) + +describe('scopeKind', () => { + it('treats :read as read and everything else as an elevated grant', () => { + expect(scopeKind('transactions:read')).toBe('read') + expect(scopeKind('transactions:write')).toBe('write') + expect(scopeKind('webhooks:manage')).toBe('write') + expect(scopeKind('pending_operations:approve')).toBe('write') + expect(scopeKind('reconciliation:signoff')).toBe('write') + }) +}) + +describe('api-keys re-exports', () => { + it('exposes the same catalogue objects so server imports keep working', () => { + expect(apiKeys.API_KEY_SCOPES).toBe(API_KEY_SCOPES) + expect(apiKeys.SCOPE_GROUPS).toBe(SCOPE_GROUPS) + expect(apiKeys.TOOL_SCOPE_MAP).toBe(TOOL_SCOPE_MAP) + expect(apiKeys.TOOL_COUNT_BY_SCOPE).toBe(TOOL_COUNT_BY_SCOPE) + expect(apiKeys.ALL_SCOPES).toBe(ALL_SCOPES) + }) +}) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 8c23748f..882bb130 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -10,376 +10,23 @@ const KEY_PREFIX = 'gnubok_sk_' const REFRESH_TOKEN_PREFIX = 'gnubok_rt_' // ── 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, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' }, - 'customers:read': { label: 'Kunder: läs', description: 'Lista kunder (1 verktyg)' }, - 'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder (2 verktyg)' }, - 'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' }, - 'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar (2 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, hitta verifikat-kandidater (3 verktyg)' }, - 'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' }, - 'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' }, - 'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' }, - 'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' }, - 'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' }, - // v1 REST API: added Phase 1 - 'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' }, - 'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' }, - 'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' }, - 'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' }, - 'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' }, - 'documents:read': { label: 'Dokument: läs', description: 'Lista och hämta dokumentbilagor' }, - 'documents:write': { label: 'Dokument: skriv', description: 'Ladda upp och koppla dokument till verifikationer' }, - 'compliance:read': { label: 'Compliance: läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' }, - 'skatteverket:write': { label: 'Skatteverket: skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' }, - 'agent:read': { label: 'Agent: läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' }, - 'agent:write': { label: 'Agent: skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' }, - 'pending_operations:read': { label: 'Stagade operationer: läs', description: 'Lista pending_operations (staged writes awaiting approval)' }, - 'pending_operations:approve': { label: 'Stagade operationer: godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP: agenten ersätter web-UI:s granskning' }, - // Reconciliation (account-keyed: bank accounts + skattekonto). Reads cover - // the account list, the bridge and the item buckets; writes cover links - // (match/unmatch) and ignore flags. Links never touch the ledger. - 'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' }, - 'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' }, - 'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' }, -} 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', - 'articles:read', - 'invoices:read', - 'suppliers:read', - 'reports:read', -] - -/** - * Default scope grant for OAuth-issued keys when the client did not pass an - * explicit `scope` parameter at /authorize. Read-only by design: every - * write or approval scope must be requested explicitly by the client AND - * affirmatively ticked by the user on the consent screen. - * - * Rationale (do not weaken without a documented security decision): - * - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary - * access set must be the silent baseline. - * - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities - * (write, approve) must not be bundled into a default grant. - * - Segregation of Duties (findStageApproveConflict below): granting any - * STAGING_SCOPES member together with `pending_operations:approve` on a - * single key lets an automated agent both stage AND commit financial - * postings without a human-in-the-loop review. Keeping the default - * read-only prevents this combination from being silently issued. - * - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that - * create or modify verifikationer must be opt-in at the authorization - * layer; conversational acknowledgement at the agent layer is not an - * auditable substitute. - */ -export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [ - 'transactions:read', - 'customers:read', - 'articles:read', - 'invoices:read', - 'suppliers:read', - 'reports:read', - 'companies:read', - 'events:read', - 'operations:read', - 'documents:read', - 'compliance:read', - 'payroll:read', - 'pending_operations:read', -] - -/** - * Scopes advertised in the RFC 8414 authorization-server metadata document - * (/.well-known/oauth-authorization-server). Restricted to the same set that - * /authorize will grant by default: destructive scopes still work when - * requested explicitly, they just aren't enumerated for unauthenticated - * callers (defense-in-depth against scope-escalation reconnaissance). - */ -export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES] - -/** - * Scopes that allow staging a pending_operation. Used to detect a - * segregation-of-duties conflict when paired with `pending_operations:approve` - * on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1). - * - * Documented system control (BFNAR 2013:2 systemdokumentation): `agent:write` - * is deliberately NOT a staging scope. The memory tools it gates - * (gnubok_remember_fact/forget_fact) write advisory agent context: they - * cannot create, mutate, or stage räkenskapsinformation, so memory-write + - * approve on one key does not let an agent both stage and commit bookkeeping. - * If a future memory surface ever feeds DIRECTLY into voucher generation - * (rather than via a separately staged-and-approved operation), revisit this - * classification. - */ -export const STAGING_SCOPES: ApiKeyScope[] = [ - 'transactions:write', - 'customers:write', - 'articles:write', - 'invoices:write', - 'suppliers:write', - 'bookkeeping:write', - 'payroll:write', - 'documents:write', - 'companies:write', - // Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a - // key holding both this and pending_operations:approve is a SoD conflict: - // findStageApproveConflict picks it up automatically from this list. - 'skatteverket:write', - // gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_* - // operations; same SoD reasoning. - 'reconciliation:write', - // gnubok_reconcile_signoff stages reconciliation_signoff. - 'reconciliation:signoff', -] - -/** - * Detect a segregation-of-duties conflict between staging and approval scopes - * on the same key. Returns the offending staging scope, or null when the - * combination is clean. Callers may choose to block, warn, or record an - * acknowledged risk acceptance. - * - * Granting both stage+approve to the same actor lets an automated agent both - * stage AND commit financial postings without a human-in-the-loop review, - * which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik) - * and BFL 5 kap 5§ traceability requirements. - */ -export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null { - if (!scopes.includes('pending_operations:approve')) return null - return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null -} - -/** Scope domain groups for UI rendering */ -export const SCOPE_GROUPS = [ - { domain: 'companies', label: 'Företag', read: 'companies:read' as const, write: 'companies:write' as const }, - { 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: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles: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: 'suppliers:write' as const }, - { domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null }, - { domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const }, - { domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const }, - { domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const }, - { domain: 'agent', label: 'Agent', read: 'agent:read' as const, write: 'agent:write' as const }, - { domain: 'skatteverket', label: 'Skatteverket', read: null, write: 'skatteverket:write' as const }, -] as const - -/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */ -export const TOOL_SCOPE_MAP: Record = { - // Companies - gnubok_list_companies: 'companies:read', - gnubok_lookup_company: 'companies:read', - gnubok_create_company: 'companies:write', - gnubok_connect_bank: 'companies:read', - gnubok_connect_skatteverket: 'companies:read', - gnubok_get_company_settings: 'companies:read', - gnubok_update_company_settings: 'companies:write', - // Transactions - gnubok_list_uncategorized_transactions: 'transactions:read', - gnubok_list_cash_accounts: 'transactions:read', - gnubok_list_transactions_without_documents: 'transactions:read', - gnubok_create_transactions: 'transactions:write', - 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', - gnubok_link_transaction_to_journal_entry: 'transactions:write', - gnubok_match_batch_allocate: 'transactions:write', - // Reconciliation (account-keyed). gnubok_get_reconciliation_status keeps its - // historical reports:read so existing keys are not cut off. - gnubok_list_reconciliation_items: 'reconciliation:read', - gnubok_reconcile_match: 'reconciliation:write', - gnubok_reconcile_unmatch: 'reconciliation:write', - gnubok_reconcile_signoff: 'reconciliation:signoff', - // Residual booking writes a verifikat: the same scope that books a bank row. - gnubok_reconcile_residual: 'transactions:write', - gnubok_bulk_book_transactions: 'transactions:write', - gnubok_bulk_book_inbox_items: 'transactions:write', - gnubok_auto_match_period: 'transactions:write', - // Customers - gnubok_list_customers: 'customers:read', - gnubok_create_customer: 'customers:write', - gnubok_update_customer: 'customers:write', - // Articles (artikelregister) - gnubok_list_articles: 'articles:read', - gnubok_create_article: 'articles:write', - gnubok_update_article: 'articles:write', - // Invoices - gnubok_list_invoices: 'invoices:read', - gnubok_get_invoice_deliveries: 'invoices:read', - gnubok_create_invoice: 'invoices:write', - gnubok_update_invoice: 'invoices:write', - gnubok_send_invoice: 'invoices:write', - gnubok_mark_invoice_as_paid: 'invoices:write', - gnubok_mark_invoice_as_sent: 'invoices:write', - // Recurring invoice schedules (staged template writes; no send/book at commit) - gnubok_list_recurring_schedules: 'invoices:read', - gnubok_create_recurring_schedule: 'invoices:write', - gnubok_update_recurring_schedule: '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_vat_review_widget: 'reports:read', - gnubok_vat_close_check: 'reports:read', - gnubok_get_kpi_report: 'reports:read', - gnubok_get_income_statement: 'reports:read', - gnubok_list_accounts: 'reports:read', - // Kontoplan management: staged reference-data writes - gnubok_create_account: 'bookkeeping:write', - gnubok_update_account: 'bookkeeping:write', - // Verifikat annotation (notes-only edit: allowed on posted entries) - gnubok_set_voucher_note: 'bookkeeping:write', - gnubok_get_balance_sheet: 'reports:read', - gnubok_get_general_ledger: 'reports:read', - gnubok_query_journal: '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', - gnubok_list_accrual_schedules: 'reports:read', - // Dimensions (kostnadsställe/projekt) registry: reads next to the report - // tools; the staged value-create is a bookkeeping write (dimensions PR3). - gnubok_list_dimensions: 'reports:read', - gnubok_list_dimension_values: 'reports:read', - gnubok_create_dimension_value: 'bookkeeping:write', - gnubok_get_dimension_pnl: 'reports:read', - // Staged bulk retag of posted-line dimensions (dimensions PR6). - gnubok_tag_journal_lines: 'bookkeeping:write', - // Document inbox - gnubok_create_document_upload: 'transactions:write', - gnubok_complete_document_upload: 'transactions:write', - gnubok_upload_document: 'transactions:write', - gnubok_list_inbox_items: 'transactions:read', - gnubok_get_inbox_item: 'transactions:read', - gnubok_list_unmatched_documents: 'transactions:read', - gnubok_get_document_content: 'transactions:read', - gnubok_attach_document_to_transaction: 'transactions:write', - gnubok_link_document_to_voucher: 'bookkeeping:write', - gnubok_link_documents_to_vouchers: 'bookkeeping:write', - // Körjournal (mileage): trip log reads/writes are payroll surface - // (milersättning, 7331); booking the verifikat is a journal write. - gnubok_list_mileage_trips: 'payroll:read', - gnubok_log_mileage_trip: 'payroll:write', - gnubok_book_mileage_period: 'bookkeeping:write', - // Payroll - gnubok_list_employees: 'payroll:read', - gnubok_get_salary_run: 'payroll:read', - gnubok_get_salary_journal: 'payroll:read', - gnubok_create_salary_run: 'payroll:write', - gnubok_calculate_salary_run: 'payroll:write', - gnubok_book_salary_run: 'payroll:write', - gnubok_generate_agi: 'payroll:write', - // Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4) - gnubok_get_employee: 'payroll:read', - gnubok_get_payslip: 'payroll:read', - gnubok_list_absence: 'payroll:read', - gnubok_update_payslip_line: 'payroll:write', - gnubok_register_absence: 'payroll:write', - gnubok_delete_absence: 'payroll:write', - gnubok_create_employee: 'payroll:write', - gnubok_update_employee: 'payroll:write', - gnubok_set_employee_opening_balances: 'payroll:write', - gnubok_get_vacation_balance: 'payroll:read', - gnubok_close_vacation_year: 'payroll:write', - // Bookkeeping write (Stream 1 Phase 1): high-risk, always staged - gnubok_close_period: 'bookkeeping:write', - gnubok_lock_period: 'bookkeeping:write', - gnubok_unlock_period: 'bookkeeping:write', - gnubok_run_year_end: 'bookkeeping:write', - gnubok_post_kontantmetod_cutoff: 'bookkeeping:write', - gnubok_year_end_readiness: 'reports:read', - gnubok_set_opening_balances: 'bookkeeping:write', - gnubok_run_currency_revaluation: 'bookkeeping:write', - gnubok_explain_voucher_gap: 'bookkeeping:write', - gnubok_list_voucher_gaps: 'reports:read', - // Transaction reversal (medium-risk) - gnubok_uncategorize_transaction: 'transactions:write', - // SIE export (read-only) + import (write) - gnubok_export_sie: 'reports:read', - gnubok_audit_package: 'reports:read', - gnubok_import_sie: 'bookkeeping:write', - // Rot/rut begäran om utbetalning (records a payout request on generate) - gnubok_generate_rot_rut_file: 'invoices:write', - // Supplier CRUD - gnubok_create_supplier: 'suppliers:write', - // Supplier invoice lifecycle - gnubok_approve_supplier_invoice: 'suppliers:write', - gnubok_credit_supplier_invoice: 'suppliers:write', - gnubok_create_supplier_invoice_from_inbox: 'suppliers:write', - gnubok_set_inbox_extracted_data: 'suppliers:write', - // Supplier invoice payment via existing verifikat (no new bokföring) - gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read', - gnubok_link_supplier_invoice_to_voucher: 'suppliers:write', - // Invoice conversion + crediting - gnubok_convert_invoice: 'invoices:write', - gnubok_credit_invoice: 'invoices:write', - // Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged) - gnubok_create_voucher: 'bookkeeping:write', - gnubok_correct_entry: 'bookkeeping:write', - gnubok_reverse_journal_entry: 'bookkeeping:write', - // Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific - // profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill - // stay unscoped (discovery + static Markdown bodies + globally-readable atom - // registry: no per-company data). - gnubok_get_agent_briefing: 'agent:read', - // Agent memory write (previously UNMAPPED → callable by any key). Mapping to - // agent:write; existing non-revoked keys are grandfathered in the - // 20260619140000 migration so this does not regress them. - gnubok_remember_fact: 'agent:write', - gnubok_forget_fact: 'agent:write', - // Pending operations approval (mirrors the /pending web UI) - gnubok_list_pending_operations: 'pending_operations:read', - gnubok_approve_pending_operation: 'pending_operations:approve', - gnubok_reject_pending_operation: 'pending_operations:approve', - // Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI); - // the two submit tools require the opt-in skatteverket:write staging scope. - gnubok_vat_declaration_validate: 'compliance:read', - gnubok_vat_declaration_status: 'compliance:read', - gnubok_agi_status: 'compliance:read', - gnubok_vat_declaration_submit: 'skatteverket:write', - gnubok_agi_submit: 'skatteverket:write', - - // ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ── - // These tools shipped without a scope mapping, making them callable by ANY - // authenticated key. Mapping them is accept-the-break by decision - // (2026-07-13): keys that relied on the default-allow hole lose access - // until granted the proper scope. Release-note callout required for the - // four WRITES below. - gnubok_link_invoice_to_voucher: 'invoices:write', - gnubok_undo_sie_import: 'bookkeeping:write', - gnubok_post_annual_depreciation: 'bookkeeping:write', - gnubok_import_rot_rut_beslut: 'invoices:write', - gnubok_list_verifikat_without_documents: 'transactions:read', - gnubok_find_voucher_candidates_for_invoice: 'invoices:read', - gnubok_propose_dispositioner: 'reports:read', - gnubok_propose_accruals: 'reports:read', - gnubok_propose_annual_depreciation: 'reports:read', - gnubok_preview_arsredovisning: 'reports:read', - gnubok_validate_arsredovisning: 'reports:read', - gnubok_list_arsredovisning_versions: 'reports:read', - gnubok_get_arsredovisning_filing_status: 'reports:read', - gnubok_preview_ef_declaration: 'reports:read', - // Deliberately UNSCOPED (available to any authenticated key): - // gnubok_search_tools, gnubok_list_skills, gnubok_load_skill, - // gnubok_feedback. Discovery + static skill bodies + feedback channel - // carry no per-company data; keeping them open is what lets an agent - // orient itself before its key's scopes are known. -} +// The catalogue lives in scope-catalog.ts (no server imports, safe for client +// bundles) and is re-exported here so existing imports keep working. +export { + API_KEY_SCOPES, + ALL_SCOPES, + DEFAULT_SCOPES, + DEFAULT_OAUTH_SCOPES, + PUBLIC_OAUTH_METADATA_SCOPES, + STAGING_SCOPES, + findStageApproveConflict, + SCOPE_GROUPS, + scopeKind, + TOOL_SCOPE_MAP, + TOOL_COUNT_BY_SCOPE, +} from './scope-catalog' +export type { ApiKeyScope, ScopeGroup } from './scope-catalog' +import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog' export function validateScopes(scopes: unknown): ApiKeyScope[] | null { if (scopes === null || scopes === undefined) return null diff --git a/lib/auth/scope-catalog.ts b/lib/auth/scope-catalog.ts new file mode 100644 index 00000000..4ebc57f2 --- /dev/null +++ b/lib/auth/scope-catalog.ts @@ -0,0 +1,420 @@ +/** + * API-key scope catalogue: scope ids, labels, groups and the MCP tool map. + * + * Pure data with no server imports, so client components (the settings + * panel) can bundle it without dragging in crypto or the service-role + * Supabase client that lib/auth/api-keys.ts needs. api-keys.ts re-exports + * everything here; server code keeps importing from there. + */ + +// ── API Key Scopes ────────────────────────────────────────── + +export const API_KEY_SCOPES = { + 'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag' }, + 'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura' }, + 'customers:read': { label: 'Kunder: läs', description: 'Lista kunder' }, + 'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder' }, + 'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret' }, + 'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar' }, + 'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor' }, + 'invoices:write': { label: 'Fakturor: skriv', description: 'Skapa, skicka, markera betald/skickad' }, + 'suppliers:read': { label: 'Leverantörer: läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater' }, + 'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor' }, + 'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export' }, + 'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' }, + 'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' }, + 'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' }, + // v1 REST API: added Phase 1 + 'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' }, + 'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' }, + 'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' }, + 'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' }, + 'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' }, + 'documents:read': { label: 'Dokument: läs', description: 'Lista och hämta dokumentbilagor' }, + 'documents:write': { label: 'Dokument: skriv', description: 'Ladda upp och koppla dokument till verifikationer' }, + 'compliance:read': { label: 'Compliance: läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' }, + 'skatteverket:write': { label: 'Skatteverket: skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' }, + 'agent:read': { label: 'Agent: läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' }, + 'agent:write': { label: 'Agent: skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' }, + 'pending_operations:read': { label: 'Stagade operationer: läs', description: 'Lista pending_operations (staged writes awaiting approval)' }, + 'pending_operations:approve': { label: 'Stagade operationer: godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP: agenten ersätter web-UI:s granskning' }, + // Reconciliation (account-keyed: bank accounts + skattekonto). Reads cover + // the account list, the bridge and the item buckets; writes cover links + // (match/unmatch) and ignore flags. Links never touch the ledger. + 'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' }, + 'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' }, + 'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' }, +} 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', + 'articles:read', + 'invoices:read', + 'suppliers:read', + 'reports:read', +] + +/** + * Default scope grant for OAuth-issued keys when the client did not pass an + * explicit `scope` parameter at /authorize. Read-only by design: every + * write or approval scope must be requested explicitly by the client AND + * affirmatively ticked by the user on the consent screen. + * + * Rationale (do not weaken without a documented security decision): + * - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary + * access set must be the silent baseline. + * - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities + * (write, approve) must not be bundled into a default grant. + * - Segregation of Duties (findStageApproveConflict below): granting any + * STAGING_SCOPES member together with `pending_operations:approve` on a + * single key lets an automated agent both stage AND commit financial + * postings without a human-in-the-loop review. Keeping the default + * read-only prevents this combination from being silently issued. + * - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that + * create or modify verifikationer must be opt-in at the authorization + * layer; conversational acknowledgement at the agent layer is not an + * auditable substitute. + */ +export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [ + 'transactions:read', + 'customers:read', + 'articles:read', + 'invoices:read', + 'suppliers:read', + 'reports:read', + 'companies:read', + 'events:read', + 'operations:read', + 'documents:read', + 'compliance:read', + 'payroll:read', + 'pending_operations:read', +] + +/** + * Scopes advertised in the RFC 8414 authorization-server metadata document + * (/.well-known/oauth-authorization-server). Restricted to the same set that + * /authorize will grant by default: destructive scopes still work when + * requested explicitly, they just aren't enumerated for unauthenticated + * callers (defense-in-depth against scope-escalation reconnaissance). + */ +export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES] + +/** + * Scopes that allow staging a pending_operation. Used to detect a + * segregation-of-duties conflict when paired with `pending_operations:approve` + * on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1). + * + * Documented system control (BFNAR 2013:2 systemdokumentation): `agent:write` + * is deliberately NOT a staging scope. The memory tools it gates + * (gnubok_remember_fact/forget_fact) write advisory agent context: they + * cannot create, mutate, or stage räkenskapsinformation, so memory-write + + * approve on one key does not let an agent both stage and commit bookkeeping. + * If a future memory surface ever feeds DIRECTLY into voucher generation + * (rather than via a separately staged-and-approved operation), revisit this + * classification. + */ +export const STAGING_SCOPES: ApiKeyScope[] = [ + 'transactions:write', + 'customers:write', + 'articles:write', + 'invoices:write', + 'suppliers:write', + 'bookkeeping:write', + 'payroll:write', + 'documents:write', + 'companies:write', + // Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a + // key holding both this and pending_operations:approve is a SoD conflict: + // findStageApproveConflict picks it up automatically from this list. + 'skatteverket:write', + // gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_* + // operations; same SoD reasoning. + 'reconciliation:write', + // gnubok_reconcile_signoff stages reconciliation_signoff. + 'reconciliation:signoff', +] + +/** + * Detect a segregation-of-duties conflict between staging and approval scopes + * on the same key. Returns the offending staging scope, or null when the + * combination is clean. Callers may choose to block, warn, or record an + * acknowledged risk acceptance. + * + * Granting both stage+approve to the same actor lets an automated agent both + * stage AND commit financial postings without a human-in-the-loop review, + * which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik) + * and BFL 5 kap 5§ traceability requirements. + */ +export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null { + if (!scopes.includes('pending_operations:approve')) return null + return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null +} + +/** + * One entry per scope group, shared by every surface that lets a human pick + * scopes (settings panel, OAuth consent page). Every scope in API_KEY_SCOPES + * belongs to exactly one group: lib/auth/__tests__/scope-catalog.test.ts + * enforces it, so a scope added to the catalogue without a group fails CI + * instead of silently vanishing from the pickers. + */ +export type ScopeGroup = { + /** Stable id: React key and i18n suffix (`group_`) in the panel. */ + domain: string + /** Swedish label for surfaces without next-intl (the OAuth consent page). */ + label: string + /** Display order: the `:read` scope first, then the elevated ones. */ + scopes: readonly ApiKeyScope[] +} + +export const SCOPE_GROUPS: readonly ScopeGroup[] = [ + { domain: 'companies', label: 'Företag', scopes: ['companies:read', 'companies:write'] }, + { domain: 'transactions', label: 'Transaktioner', scopes: ['transactions:read', 'transactions:write'] }, + { domain: 'reconciliation', label: 'Avstämning', scopes: ['reconciliation:read', 'reconciliation:write', 'reconciliation:signoff'] }, + { domain: 'customers', label: 'Kunder', scopes: ['customers:read', 'customers:write'] }, + { domain: 'articles', label: 'Artiklar', scopes: ['articles:read', 'articles:write'] }, + { domain: 'invoices', label: 'Fakturor', scopes: ['invoices:read', 'invoices:write'] }, + { domain: 'suppliers', label: 'Leverantörer', scopes: ['suppliers:read', 'suppliers:write'] }, + { domain: 'reports', label: 'Rapporter', scopes: ['reports:read'] }, + { domain: 'bookkeeping', label: 'Bokföring', scopes: ['bookkeeping:write'] }, + { domain: 'payroll', label: 'Löner', scopes: ['payroll:read', 'payroll:write'] }, + { domain: 'documents', label: 'Dokument', scopes: ['documents:read', 'documents:write'] }, + { domain: 'pending_operations', label: 'Stagade operationer', scopes: ['pending_operations:read', 'pending_operations:approve'] }, + { domain: 'agent', label: 'Agent', scopes: ['agent:read', 'agent:write'] }, + { domain: 'skatteverket', label: 'Skatteverket', scopes: ['skatteverket:write'] }, + { domain: 'compliance', label: 'Compliance', scopes: ['compliance:read'] }, + { domain: 'events', label: 'Händelser', scopes: ['events:read'] }, + { domain: 'webhooks', label: 'Webhooks', scopes: ['webhooks:manage'] }, + { domain: 'operations', label: 'Operationer', scopes: ['operations:read'] }, +] + +/** + * Read scopes are the implicit baseline; everything else (write, manage, + * approve, signoff) is an elevated grant and is rendered as such. + */ +export function scopeKind(scope: ApiKeyScope): 'read' | 'write' { + return scope.endsWith(':read') ? 'read' : 'write' +} + +/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */ +export const TOOL_SCOPE_MAP: Record = { + // Companies + gnubok_list_companies: 'companies:read', + gnubok_create_company: 'companies:write', + gnubok_lookup_company: 'companies:read', + gnubok_connect_bank: 'companies:read', + gnubok_connect_skatteverket: 'companies:read', + gnubok_get_company_settings: 'companies:read', + gnubok_update_company_settings: 'companies:write', + // Transactions + gnubok_list_uncategorized_transactions: 'transactions:read', + gnubok_list_cash_accounts: 'transactions:read', + gnubok_list_transactions_without_documents: 'transactions:read', + gnubok_create_transactions: 'transactions:write', + 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', + gnubok_link_transaction_to_journal_entry: 'transactions:write', + gnubok_match_batch_allocate: 'transactions:write', + // Reconciliation (account-keyed). gnubok_get_reconciliation_status keeps its + // historical reports:read so existing keys are not cut off. + gnubok_list_reconciliation_items: 'reconciliation:read', + gnubok_reconcile_match: 'reconciliation:write', + gnubok_reconcile_unmatch: 'reconciliation:write', + gnubok_reconcile_signoff: 'reconciliation:signoff', + // Residual booking writes a verifikat: the same scope that books a bank row. + gnubok_reconcile_residual: 'transactions:write', + gnubok_bulk_book_transactions: 'transactions:write', + gnubok_bulk_book_inbox_items: 'transactions:write', + gnubok_auto_match_period: 'transactions:write', + // Customers + gnubok_list_customers: 'customers:read', + gnubok_create_customer: 'customers:write', + gnubok_update_customer: 'customers:write', + // Articles (artikelregister) + gnubok_list_articles: 'articles:read', + gnubok_create_article: 'articles:write', + gnubok_update_article: 'articles:write', + // Invoices + gnubok_list_invoices: 'invoices:read', + gnubok_get_invoice_deliveries: 'invoices:read', + gnubok_create_invoice: 'invoices:write', + gnubok_update_invoice: 'invoices:write', + gnubok_send_invoice: 'invoices:write', + gnubok_mark_invoice_as_paid: 'invoices:write', + gnubok_mark_invoice_as_sent: 'invoices:write', + // Recurring invoice schedules (staged template writes; no send/book at commit) + gnubok_list_recurring_schedules: 'invoices:read', + gnubok_create_recurring_schedule: 'invoices:write', + gnubok_update_recurring_schedule: '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_vat_review_widget: 'reports:read', + gnubok_vat_close_check: 'reports:read', + gnubok_get_kpi_report: 'reports:read', + gnubok_get_income_statement: 'reports:read', + gnubok_list_accounts: 'reports:read', + // Kontoplan management: staged reference-data writes + gnubok_create_account: 'bookkeeping:write', + gnubok_update_account: 'bookkeeping:write', + // Verifikat annotation (notes-only edit: allowed on posted entries) + gnubok_set_voucher_note: 'bookkeeping:write', + gnubok_get_balance_sheet: 'reports:read', + gnubok_get_general_ledger: 'reports:read', + gnubok_query_journal: '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', + gnubok_list_accrual_schedules: 'reports:read', + // Dimensions (kostnadsställe/projekt) registry: reads next to the report + // tools; the staged value-create is a bookkeeping write (dimensions PR3). + gnubok_list_dimensions: 'reports:read', + gnubok_list_dimension_values: 'reports:read', + gnubok_create_dimension_value: 'bookkeeping:write', + gnubok_get_dimension_pnl: 'reports:read', + // Staged bulk retag of posted-line dimensions (dimensions PR6). + gnubok_tag_journal_lines: 'bookkeeping:write', + // Document inbox + gnubok_create_document_upload: 'transactions:write', + gnubok_complete_document_upload: 'transactions:write', + gnubok_upload_document: 'transactions:write', + gnubok_list_inbox_items: 'transactions:read', + gnubok_get_inbox_item: 'transactions:read', + gnubok_list_unmatched_documents: 'transactions:read', + gnubok_get_document_content: 'transactions:read', + gnubok_attach_document_to_transaction: 'transactions:write', + gnubok_link_document_to_voucher: 'bookkeeping:write', + gnubok_link_documents_to_vouchers: 'bookkeeping:write', + // Körjournal (mileage): trip log reads/writes are payroll surface + // (milersättning, 7331); booking the verifikat is a journal write. + gnubok_list_mileage_trips: 'payroll:read', + gnubok_log_mileage_trip: 'payroll:write', + gnubok_book_mileage_period: 'bookkeeping:write', + // Payroll + gnubok_list_employees: 'payroll:read', + gnubok_get_salary_run: 'payroll:read', + gnubok_get_salary_journal: 'payroll:read', + gnubok_create_salary_run: 'payroll:write', + gnubok_calculate_salary_run: 'payroll:write', + gnubok_book_salary_run: 'payroll:write', + gnubok_generate_agi: 'payroll:write', + // Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4) + gnubok_get_employee: 'payroll:read', + gnubok_get_payslip: 'payroll:read', + gnubok_list_absence: 'payroll:read', + gnubok_update_payslip_line: 'payroll:write', + gnubok_register_absence: 'payroll:write', + gnubok_delete_absence: 'payroll:write', + gnubok_create_employee: 'payroll:write', + gnubok_update_employee: 'payroll:write', + gnubok_set_employee_opening_balances: 'payroll:write', + gnubok_get_vacation_balance: 'payroll:read', + gnubok_close_vacation_year: 'payroll:write', + // Bookkeeping write (Stream 1 Phase 1): high-risk, always staged + gnubok_close_period: 'bookkeeping:write', + gnubok_lock_period: 'bookkeeping:write', + gnubok_unlock_period: 'bookkeeping:write', + gnubok_run_year_end: 'bookkeeping:write', + gnubok_post_kontantmetod_cutoff: 'bookkeeping:write', + gnubok_year_end_readiness: 'reports:read', + gnubok_set_opening_balances: 'bookkeeping:write', + gnubok_run_currency_revaluation: 'bookkeeping:write', + gnubok_explain_voucher_gap: 'bookkeeping:write', + gnubok_list_voucher_gaps: 'reports:read', + // Transaction reversal (medium-risk) + gnubok_uncategorize_transaction: 'transactions:write', + // SIE export (read-only) + import (write) + gnubok_export_sie: 'reports:read', + gnubok_audit_package: 'reports:read', + gnubok_import_sie: 'bookkeeping:write', + // Rot/rut begäran om utbetalning (records a payout request on generate) + gnubok_generate_rot_rut_file: 'invoices:write', + // Supplier CRUD + gnubok_create_supplier: 'suppliers:write', + // Supplier invoice lifecycle + gnubok_approve_supplier_invoice: 'suppliers:write', + gnubok_credit_supplier_invoice: 'suppliers:write', + gnubok_create_supplier_invoice_from_inbox: 'suppliers:write', + gnubok_set_inbox_extracted_data: 'suppliers:write', + // Supplier invoice payment via existing verifikat (no new bokföring) + gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read', + gnubok_link_supplier_invoice_to_voucher: 'suppliers:write', + // Invoice conversion + crediting + gnubok_convert_invoice: 'invoices:write', + gnubok_credit_invoice: 'invoices:write', + // Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged) + gnubok_create_voucher: 'bookkeeping:write', + gnubok_correct_entry: 'bookkeeping:write', + gnubok_reverse_journal_entry: 'bookkeeping:write', + // Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific + // profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill + // stay unscoped (discovery + static Markdown bodies + globally-readable atom + // registry: no per-company data). + gnubok_get_agent_briefing: 'agent:read', + // Agent memory write (previously UNMAPPED → callable by any key). Mapping to + // agent:write; existing non-revoked keys are grandfathered in the + // 20260619140000 migration so this does not regress them. + gnubok_remember_fact: 'agent:write', + gnubok_forget_fact: 'agent:write', + // Pending operations approval (mirrors the /pending web UI) + gnubok_list_pending_operations: 'pending_operations:read', + gnubok_approve_pending_operation: 'pending_operations:approve', + gnubok_reject_pending_operation: 'pending_operations:approve', + // Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI); + // the two submit tools require the opt-in skatteverket:write staging scope. + gnubok_vat_declaration_validate: 'compliance:read', + gnubok_vat_declaration_status: 'compliance:read', + gnubok_agi_status: 'compliance:read', + gnubok_vat_declaration_submit: 'skatteverket:write', + gnubok_agi_submit: 'skatteverket:write', + + // ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ── + // These tools shipped without a scope mapping, making them callable by ANY + // authenticated key. Mapping them is accept-the-break by decision + // (2026-07-13): keys that relied on the default-allow hole lose access + // until granted the proper scope. Release-note callout required for the + // four WRITES below. + gnubok_link_invoice_to_voucher: 'invoices:write', + gnubok_undo_sie_import: 'bookkeeping:write', + gnubok_post_annual_depreciation: 'bookkeeping:write', + gnubok_import_rot_rut_beslut: 'invoices:write', + gnubok_list_verifikat_without_documents: 'transactions:read', + gnubok_find_voucher_candidates_for_invoice: 'invoices:read', + gnubok_propose_dispositioner: 'reports:read', + gnubok_propose_accruals: 'reports:read', + gnubok_propose_annual_depreciation: 'reports:read', + gnubok_preview_arsredovisning: 'reports:read', + gnubok_validate_arsredovisning: 'reports:read', + gnubok_list_arsredovisning_versions: 'reports:read', + gnubok_get_arsredovisning_filing_status: 'reports:read', + gnubok_preview_ef_declaration: 'reports:read', + // Deliberately UNSCOPED (available to any authenticated key): + // gnubok_search_tools, gnubok_list_skills, gnubok_load_skill, + // gnubok_feedback. Discovery + static skill bodies + feedback channel + // carry no per-company data; keeping them open is what lets an agent + // orient itself before its key's scopes are known. +} + +/** + * Number of MCP tools gated by each scope, derived from TOOL_SCOPE_MAP at + * module load. 0 means the scope only gates REST endpoints. Never hand-write + * these numbers into labels: they drift the moment a tool is added. + */ +export const TOOL_COUNT_BY_SCOPE: Readonly> = (() => { + const counts = Object.fromEntries(ALL_SCOPES.map((s) => [s, 0])) as Record + for (const scope of Object.values(TOOL_SCOPE_MAP)) counts[scope] += 1 + return counts +})() diff --git a/messages/en.json b/messages/en.json index e08ec122..232bbf16 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2737,16 +2737,24 @@ "group_bookkeeping": "Bookkeeping", "group_payroll": "Payroll", "group_pending_operations": "Staged operations", - "group_documents": "Documents (REST API)", - "group_companies": "Companies (REST API)", - "group_events": "Events (REST API)", - "group_webhooks": "Webhooks (REST API)", - "group_operations": "Operations (REST API)", - "group_compliance": "Compliance (REST API)", + "group_documents": "Documents", + "group_companies": "Companies", + "group_events": "Events", + "group_webhooks": "Webhooks", + "group_operations": "Operations", + "group_compliance": "Compliance", + "group_articles": "Articles", + "group_reconciliation": "Reconciliation", + "group_rest_only": "{name} (REST API)", "scope_transactions_read": "Read: list transactions, templates, categories, inbox", "scope_transactions_write": "Write: categorize, receipt matching, link to invoice, document upload", + "scope_reconciliation_read": "Read: accounts to reconcile, the bridge per account and the items behind it (bank + tax account)", + "scope_reconciliation_write": "Write: match and unmatch events against vouchers, ignore items (staged)", + "scope_reconciliation_signoff": "Sign off: mark an account as reconciled through a date, or reopen a sign-off (staged)", "scope_customers_read": "Read: list customers", "scope_customers_write": "Write: create customers", + "scope_articles_read": "Read: list articles in the article register", + "scope_articles_write": "Write: create and update articles", "scope_invoices_read": "Read: list invoices", "scope_invoices_write": "Write: create, send, mark paid/sent, credit, convert", "scope_suppliers_read": "Read: list suppliers and supplier invoices", @@ -2760,6 +2768,7 @@ "scope_documents_read": "Read: list and fetch document attachments", "scope_documents_write": "Write: upload and link documents to vouchers", "scope_companies_read": "Read: company profiles the key has access to", + "scope_companies_write": "Write: create companies and update company settings", "scope_events_read": "Read: poll event_log as a webhook fallback", "scope_webhooks_manage": "Manage: create, list, update, delete subscriptions", "scope_operations_read": "Read: status of long-running operations (import, year-end, revaluation)", diff --git a/messages/sv.json b/messages/sv.json index 91fd5708..8b920f56 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2737,16 +2737,24 @@ "group_bookkeeping": "Bokföring", "group_payroll": "Löner", "group_pending_operations": "Stagade operationer", - "group_documents": "Dokument (REST API)", - "group_companies": "Företag (REST API)", - "group_events": "Händelser (REST API)", - "group_webhooks": "Webhooks (REST API)", - "group_operations": "Operationer (REST API)", - "group_compliance": "Compliance (REST API)", + "group_documents": "Dokument", + "group_companies": "Företag", + "group_events": "Händelser", + "group_webhooks": "Webhooks", + "group_operations": "Operationer", + "group_compliance": "Compliance", + "group_articles": "Artiklar", + "group_reconciliation": "Avstämning", + "group_rest_only": "{name} (REST API)", "scope_transactions_read": "Läs: lista transaktioner, mallar, kategorier, inbox", "scope_transactions_write": "Skriv: kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning", + "scope_reconciliation_read": "Läs: konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)", + "scope_reconciliation_write": "Skriv: koppla och koppla bort händelser mot verifikat, ignorera rader (stagas)", + "scope_reconciliation_signoff": "Signera: markera ett konto som avstämt t.o.m. ett datum, eller öppna signeringen igen (stagas)", "scope_customers_read": "Läs: lista kunder", "scope_customers_write": "Skriv: skapa kunder", + "scope_articles_read": "Läs: lista artiklar i artikelregistret", + "scope_articles_write": "Skriv: skapa och uppdatera artiklar", "scope_invoices_read": "Läs: lista fakturor", "scope_invoices_write": "Skriv: skapa, skicka, markera betald/skickad, kreditera, konvertera", "scope_suppliers_read": "Läs: lista leverantörer och leverantörsfakturor", @@ -2760,6 +2768,7 @@ "scope_documents_read": "Läs: lista och hämta dokumentbilagor", "scope_documents_write": "Skriv: ladda upp och koppla dokument till verifikationer", "scope_companies_read": "Läs: företagsprofiler nyckeln har åtkomst till", + "scope_companies_write": "Skriv: skapa företag och uppdatera företagsinställningar", "scope_events_read": "Läs: polla event_log som webhook-fallback", "scope_webhooks_manage": "Hantera: skapa, lista, uppdatera, radera prenumerationer", "scope_operations_read": "Läs: status för långkörande operationer (import, bokslut, omvärdering)",