diff --git a/app/api/settings/api-keys/__tests__/route.test.ts b/app/api/settings/api-keys/__tests__/route.test.ts new file mode 100644 index 00000000..14cdf1cb --- /dev/null +++ b/app/api/settings/api-keys/__tests__/route.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +// ── Mocks ────────────────────────────────────────────────────────── +// withRouteContext resolves auth via requireAuth (createClient under the hood), +// the active company via getActiveCompanyId, and the write gate via +// requireWritePermission. Mock all three so we can drive each branch. + +const mockSupabase = { + auth: { getUser: vi.fn() }, + from: vi.fn(), +} + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const getActiveCompanyIdMock = vi.fn() +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args), +})) + +const requireWritePermissionMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args), +})) + +import { POST } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +// Records the payload passed to .insert(), and lets us program the count +// returned by the quota pre-check and the row returned by the insert. +function setupFrom(opts: { + count?: number | null + insertResult?: { data?: unknown; error?: unknown } +}) { + const insertSpy = vi.fn() + + mockSupabase.from.mockImplementation(() => { + // The quota pre-check: .select(..., { head: true }).eq().is() → resolves + // to { count }. The insert: .insert().select().single() → resolves to the + // row. We expose both via a single chainable proxy whose terminal value + // depends on whether insert() was called. + let isInsert = false + const result = () => + isInsert + ? Promise.resolve({ + data: opts.insertResult?.data ?? null, + error: opts.insertResult?.error ?? null, + }) + : Promise.resolve({ count: opts.count ?? 0, data: null, error: null }) + + const chain: Record = {} + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result() as unknown) + } + if (prop === 'insert') { + return (payload: unknown) => { + isInsert = true + insertSpy(payload) + return new Proxy(chain, handler) + } + } + if (prop === 'single' || prop === 'maybeSingle') { + return () => result() + } + return () => new Proxy(chain, handler) + }, + } + return new Proxy(chain, handler) + }) + + return { insertSpy } +} + +beforeEach(() => { + vi.clearAllMocks() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + getActiveCompanyIdMock.mockResolvedValue('company-1') + requireWritePermissionMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/settings/api-keys', () => { + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { name: 'k', scopes: ['reports:read'] }, + }), + ) + expect(res.status).toBe(401) + }) + + it('returns 400 for an invalid scope', async () => { + setupFrom({ count: 0 }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { name: 'k', scopes: ['totally:bogus'] }, + }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(400) + expect(body.error.code).toBe('API_KEY_SCOPE_INVALID') + }) + + it('returns 409 API_KEY_SOD_CONFLICT for stage+approve without acknowledgement', async () => { + setupFrom({ count: 0 }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { + name: 'k', + scopes: ['invoices:write', 'pending_operations:approve'], + }, + }), + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { conflicting_scope: string; approve_scope: string } } + }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('API_KEY_SOD_CONFLICT') + expect(body.error.details.conflicting_scope).toBe('invoices:write') + expect(body.error.details.approve_scope).toBe('pending_operations:approve') + }) + + it('records sod_acknowledged_at/by in the insert when acknowledge_sod is true', async () => { + const { insertSpy } = setupFrom({ + count: 0, + insertResult: { + data: { + id: 'ak-1', + key_prefix: 'gnubok_sk_abcd', + name: 'k', + scopes: ['invoices:write', 'pending_operations:approve'], + created_at: '2026-06-05T10:00:00Z', + }, + }, + }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { + name: 'k', + scopes: ['invoices:write', 'pending_operations:approve'], + acknowledge_sod: true, + }, + }), + ) + const { status, body } = await parseJsonResponse<{ data: { key: string } }>(res) + expect(status).toBe(200) + expect(body.data.key).toMatch(/^gnubok_sk_/) + + expect(insertSpy).toHaveBeenCalledTimes(1) + const payload = insertSpy.mock.calls[0][0] as Record + expect(payload.sod_acknowledged_by).toBe('user-1') + expect(typeof payload.sod_acknowledged_at).toBe('string') + // ISO timestamp + expect(payload.sod_acknowledged_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + }) + + it('creates a clean key without approve scope and does not set SoD fields', async () => { + const { insertSpy } = setupFrom({ + count: 0, + insertResult: { + data: { + id: 'ak-2', + key_prefix: 'gnubok_sk_efgh', + name: 'reader', + scopes: ['reports:read'], + created_at: '2026-06-05T10:00:00Z', + }, + }, + }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { name: 'reader', scopes: ['reports:read'] }, + }), + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(200) + + const payload = insertSpy.mock.calls[0][0] as Record + expect(payload).not.toHaveProperty('sod_acknowledged_at') + expect(payload).not.toHaveProperty('sod_acknowledged_by') + expect(payload.scopes).toEqual(['reports:read']) + }) +}) diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index a58e60cc..94cc392b 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' -import { generateApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys' +import { + generateApiKey, + DEFAULT_SCOPES, + validateScopes, + findStageApproveConflict, +} from '@/lib/auth/api-keys' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { ApiKeyScope } from '@/lib/auth/api-keys' @@ -38,11 +43,13 @@ export const POST = withRouteContext( let name = 'Unnamed key' let scopes: ApiKeyScope[] = DEFAULT_SCOPES + let acknowledgeSod = false try { const body = await request.json() if (body.name && typeof body.name === 'string') { name = body.name.slice(0, 100) } + acknowledgeSod = body.acknowledge_sod === true const parsed = validateScopes(body.scopes) if (parsed) { scopes = parsed @@ -56,6 +63,22 @@ export const POST = withRouteContext( // Empty body — use defaults. } + // Segregation of duties: warn + require explicit acknowledgement (not block) + // when a single key both stages bookkeeping AND can approve it. Surfacing a + // 409 lets the UI raise an explicit confirm dialog and the agent inform the + // user before re-POSTing with acknowledge_sod: true. + const conflictingScope = findStageApproveConflict(scopes) + if (conflictingScope && !acknowledgeSod) { + return errorResponseFromCode('API_KEY_SOD_CONFLICT', log, { + requestId, + details: { + conflicting_scope: conflictingScope, + approve_scope: 'pending_operations:approve', + }, + }) + } + const sodAcknowledgedAt = conflictingScope ? new Date().toISOString() : null + const { count } = await supabase .from('api_keys') .select('id', { count: 'exact', head: true }) @@ -80,6 +103,9 @@ export const POST = withRouteContext( key_prefix: prefix, name, scopes, + ...(sodAcknowledgedAt + ? { sod_acknowledged_at: sodAcknowledgedAt, sod_acknowledged_by: user.id } + : {}), }) .select('id, key_prefix, name, scopes, created_at') .single() @@ -92,6 +118,21 @@ export const POST = withRouteContext( }) } + if (sodAcknowledgedAt) { + // High-risk security event: the creator self-attested the stage+approve + // combination. The durable record is the sod_acknowledged_* pair on the + // key row; this structured entry additionally lands the acceptance in + // the logging pipeline (ASVS V16.1.1 / SOC 2 CC6.1). + log.warn('api_key.sod_acknowledged', { + keyId: data.id, + keyPrefix: data.key_prefix, + conflictingScope, + scopes, + acknowledgedBy: user.id, + companyId, + }) + } + return NextResponse.json({ data: { ...data, diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index 469b7c39..0dd4cff6 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -18,9 +18,10 @@ import { import { Checkbox } from '@/components/ui/checkbox' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { useToast } from '@/components/ui/use-toast' -import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown } from 'lucide-react' +import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react' import { cn } from '@/lib/utils' import { getBranding } from '@/lib/branding/service' +import { STAGING_SCOPES } from '@/lib/auth/api-keys' import type { ApiKeyScope } from '@/lib/auth/api-keys' const branding = getBranding() @@ -89,6 +90,12 @@ const SCOPE_GROUPS: ScopeGroup[] = [ 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', @@ -230,6 +237,7 @@ export function ApiKeysPanel() { const t = useTranslations('settings_api_keys') const { toast } = useToast() const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm() + const { dialogProps: sodDialogProps, confirm: confirmSod } = useDestructiveConfirm() const [keys, setKeys] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -242,6 +250,16 @@ export function ApiKeysPanel() { const [newKeyValue, setNewKeyValue] = useState('') const [copied, setCopied] = useState(false) + // Segregation-of-duties: a single key that both stages bookkeeping (any + // STAGING_SCOPES member) AND can approve it (pending_operations:approve) + // lets an automated agent commit financial postings with no human in the + // loop. We warn inline and require an explicit confirm before submitting + // with acknowledge_sod — the route returns 409 API_KEY_SOD_CONFLICT + // otherwise (default create ticks all scopes, so this path is the norm). + const sodConflictScope = STAGING_SCOPES.find((s) => newKeyScopes.has(s)) ?? null + const hasSodConflict = + newKeyScopes.has('pending_operations:approve') && sodConflictScope !== null + const fetchKeys = useCallback(async () => { try { const res = await fetch('/api/settings/api-keys') @@ -261,12 +279,28 @@ export function ApiKeysPanel() { }, [fetchKeys]) async function handleCreate() { + // SoD: require an explicit, auditable acknowledgement before minting a key + // that can both stage and approve postings. + if (hasSodConflict) { + const ok = await confirmSod({ + title: t('sod_dialog_title'), + description: t('sod_dialog_description'), + confirmLabel: t('sod_confirm'), + variant: 'warning', + }) + if (!ok) return + } + setIsCreating(true) try { const res = await fetch('/api/settings/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes) }), + body: JSON.stringify({ + name: newKeyName || t('default_key_name'), + scopes: Array.from(newKeyScopes), + ...(hasSodConflict ? { acknowledge_sod: true } : {}), + }), }) const json = await res.json() @@ -559,6 +593,15 @@ export function ApiKeysPanel() { ))} + {hasSodConflict && ( +
+ +

{t('sod_warning')}

+
+ )} @@ -574,6 +617,7 @@ export function ApiKeysPanel() { + {/* Show key once dialog */} { diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index 8a118841..34ebaf82 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -658,3 +658,31 @@ describe('entry_id resolution — voucher refs and hallucinated UUIDs', () => { ).rejects.toThrow(new RegExp(`id=${hallucinated}`)) }) }) + +describe('agent memory tools — agent:write scope gate', () => { + const rememberFact = tools.find((t) => t.name === 'gnubok_remember_fact') + const forgetFact = tools.find((t) => t.name === 'gnubok_forget_fact') + + it('registers the memory write tools mapped to agent:write', async () => { + const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys') + expect(rememberFact).toBeDefined() + expect(forgetFact).toBeDefined() + expect(TOOL_SCOPE_MAP.gnubok_remember_fact).toBe('agent:write') + expect(TOOL_SCOPE_MAP.gnubok_forget_fact).toBe('agent:write') + }) + + // Mirror the server's enforcement: a tool is blocked when it has a required + // scope the key does not hold (server.ts: `requiredScope && !hasScope(...)`). + it('denies a key without agent:write and allows one with it', async () => { + const { TOOL_SCOPE_MAP, hasScope } = await import('@/lib/auth/api-keys') + const required = TOOL_SCOPE_MAP.gnubok_remember_fact + + const without = ['agent:read', 'reports:read'] as never[] + const isDenied = !!required && !hasScope(without, required) + expect(isDenied).toBe(true) + + const withWrite = ['agent:read', 'agent:write'] as never[] + const isAllowed = !required || hasScope(withWrite, required) + expect(isAllowed).toBe(true) + }) +}) diff --git a/lib/auth/__tests__/api-keys.test.ts b/lib/auth/__tests__/api-keys.test.ts index e2be0c77..729c1e54 100644 --- a/lib/auth/__tests__/api-keys.test.ts +++ b/lib/auth/__tests__/api-keys.test.ts @@ -11,7 +11,12 @@ import { validateScopes, hasScope, validateApiKey, + findStageApproveConflict, DEFAULT_SCOPES, + DEFAULT_OAUTH_SCOPES, + STAGING_SCOPES, + TOOL_SCOPE_MAP, + API_KEY_SCOPES, } from '../api-keys' import { createClient } from '@supabase/supabase-js' @@ -157,6 +162,69 @@ describe('hasScope', () => { }) }) +// ============================================================ +// findStageApproveConflict +// ============================================================ + +describe('findStageApproveConflict', () => { + it('returns null when approve scope is absent', () => { + expect(findStageApproveConflict(['invoices:write', 'reports:read'])).toBeNull() + }) + + it('returns null when approve scope present but no staging scope', () => { + expect( + findStageApproveConflict(['pending_operations:approve', 'reports:read']), + ).toBeNull() + }) + + it('returns the offending staging scope when both are present', () => { + expect( + findStageApproveConflict(['invoices:write', 'pending_operations:approve']), + ).toBe('invoices:write') + }) + + it('treats every STAGING_SCOPES member as a conflict alongside approve', () => { + for (const staging of STAGING_SCOPES) { + expect(findStageApproveConflict([staging, 'pending_operations:approve'])).toBe(staging) + } + }) + + it('does NOT treat agent:write as a staging scope', () => { + expect(STAGING_SCOPES).not.toContain('agent:write') + // agent:write + approve is not a SoD conflict — memory writes don't stage + // bookkeeping that approve would commit. + expect( + findStageApproveConflict(['agent:write', 'pending_operations:approve']), + ).toBeNull() + }) +}) + +// ============================================================ +// agent:write scope wiring +// ============================================================ + +describe('agent:write scope', () => { + it('is a registered scope with a Swedish label and description', () => { + expect(API_KEY_SCOPES['agent:write']).toBeDefined() + expect(API_KEY_SCOPES['agent:write'].label).toBe('Agent — skriv') + expect(typeof API_KEY_SCOPES['agent:write'].description).toBe('string') + }) + + it('maps the memory write tools to agent:write', () => { + expect(TOOL_SCOPE_MAP.gnubok_remember_fact).toBe('agent:write') + expect(TOOL_SCOPE_MAP.gnubok_forget_fact).toBe('agent:write') + }) + + it('keeps gnubok_get_agent_briefing on agent:read', () => { + expect(TOOL_SCOPE_MAP.gnubok_get_agent_briefing).toBe('agent:read') + }) + + it('is excluded from the default scope grants', () => { + expect(DEFAULT_SCOPES).not.toContain('agent:write') + expect(DEFAULT_OAUTH_SCOPES).not.toContain('agent:write') + }) +}) + // ============================================================ // validateApiKey // ============================================================ diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 290e402d..11675595 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -28,6 +28,7 @@ export const API_KEY_SCOPES = { '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' }, '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' }, } as const @@ -94,6 +95,15 @@ export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCO * 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', @@ -131,6 +141,7 @@ export const SCOPE_GROUPS = [ { 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 }, ] 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). */ @@ -227,6 +238,11 @@ export const TOOL_SCOPE_MAP: Record = { // 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', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 977d7284..e8fb3f4e 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1742,6 +1742,17 @@ const API_KEY: Record = { message_sv: 'API-nyckeln kunde inte hittas.', message_en: 'API key not found.', }, + API_KEY_SOD_CONFLICT: { + httpStatus: 409, + message_sv: + 'Nyckeln kombinerar ett skriv-scope som stagar bokföring med pending_operations:approve. Då kan en automatiserad agent både skapa och godkänna verifikationer utan mänsklig granskning (ansvarsfördelning, ISO 27001 A.5.3 / BFNAR 2013:2). Bekräfta att du förstår risken för att skapa nyckeln ändå.', + message_en: + 'This key combines a staging write scope with pending_operations:approve, letting an automated agent both stage and approve postings with no human in the loop (segregation of duties, ISO 27001 A.5.3 / BFNAR 2013:2).', + remediation: { + description: + 'Inform the user of the segregation-of-duties risk, then re-POST the same scopes with acknowledge_sod: true to create the key anyway.', + }, + }, } // ───────────────────────────────────────────────────────────────── diff --git a/messages/en.json b/messages/en.json index 81732e98..46170abc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1490,7 +1490,14 @@ "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)", - "scope_compliance_read": "Read — pre-flight: VAT closing, year-end readiness, voucher gaps, IB/UB continuity" + "scope_compliance_read": "Read — pre-flight: VAT closing, year-end readiness, voucher gaps, IB/UB continuity", + "group_agent": "Agent", + "scope_agent_read": "Read — agent briefing: profile, loaded specialists, saved memories", + "scope_agent_write": "Write — save and remove the agent's memories about the company", + "sod_warning": "This key can both create bookkeeping and approve it. That lets an automated agent commit postings with no human review (segregation of duties).", + "sod_dialog_title": "Confirm combined permissions", + "sod_dialog_description": "This key combines a staging write scope with permission to approve staged operations. That lets an automated agent both create and approve postings without a human reviewing them. Create the key anyway?", + "sod_confirm": "Create anyway" }, "settings_oauth_clients": { "title": "OAuth clients", diff --git a/messages/sv.json b/messages/sv.json index ed74c7c6..546060e3 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1490,7 +1490,14 @@ "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)", - "scope_compliance_read": "Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet" + "scope_compliance_read": "Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet", + "group_agent": "Agent", + "scope_agent_read": "Läs — agentens briefing: profil, laddade specialister, sparade minnen", + "scope_agent_write": "Skriv — spara och ta bort agentens minnen om företaget", + "sod_warning": "Den här nyckeln kan både skapa bokföring och godkänna den. Då kan en automatiserad agent committa verifikationer utan mänsklig granskning (ansvarsfördelning).", + "sod_dialog_title": "Bekräfta kombinerad behörighet", + "sod_dialog_description": "Nyckeln kombinerar ett skriv-scope som stagar bokföring med behörighet att godkänna stagade operationer. Då kan en automatiserad agent både skapa och godkänna verifikationer utan att en människa granskar dem. Skapa nyckeln ändå?", + "sod_confirm": "Skapa ändå" }, "settings_oauth_clients": { "title": "OAuth-klienter", diff --git a/supabase/migrations/20260619140000_api_keys_sod_ack_and_agent_write.sql b/supabase/migrations/20260619140000_api_keys_sod_ack_and_agent_write.sql new file mode 100644 index 00000000..7df97a0c --- /dev/null +++ b/supabase/migrations/20260619140000_api_keys_sod_ack_and_agent_write.sql @@ -0,0 +1,75 @@ +-- API keys: segregation-of-duties acknowledgement + agent:write scope grandfathering +-- +-- Part 1 — SoD acknowledgement columns +-- When a key is minted with both a staging write scope AND +-- pending_operations:approve, the create route warns and requires an explicit +-- acknowledgement (warn + confirm, not block). We record who acknowledged the +-- combined risk and when, so the acceptance is auditable (ISO 27001:2022 +-- A.5.3 segregation of duties / SOC 2 CC6.1; BFNAR 2013:2 behandlingshistorik). +-- +-- DELIBERATE DESIGN: this is a SELF-attestation — sod_acknowledged_by is the +-- creating user, not a second approver. The product serves enskilda firmor +-- where a second person frequently does not exist, and the claude.ai chat +-- approval flow legitimately requires stage+approve on one credential. The +-- control objective is informed consent + an auditable record, not dual +-- control; hard blocking was considered and rejected (see PR #681). +-- +-- Part 2 — agent:write grandfathering +-- The memory tools gnubok_remember_fact / gnubok_forget_fact were previously +-- UNMAPPED in TOOL_SCOPE_MAP, which meant they were callable by ANY +-- authenticated key (tools omitted from the map are unscoped). This migration +-- introduces the agent:write scope and maps those two tools to it. Mapping a +-- previously-unscoped tool would silently break every existing key that relies +-- on the old "callable by any key" behaviour. To preserve that behaviour we +-- grandfather agent:write onto all existing non-revoked keys that already have +-- an explicit scope list. New keys must opt in to agent:write explicitly. +-- +-- Reference (verified against supabase/migrations/20260320120000_api_keys.sql): +-- public.api_keys.scopes text[] (NULL = legacy full/default access) +-- public.api_keys.revoked_at timestamptz (NULL = active key) + +ALTER TABLE public.api_keys + ADD COLUMN IF NOT EXISTS sod_acknowledged_at timestamptz, + ADD COLUMN IF NOT EXISTS sod_acknowledged_by uuid REFERENCES auth.users(id); + +-- The acknowledgement is only auditable as a pair (WHO accepted WHEN) — enforce +-- both-or-neither at the DB layer so a partial write can never silently pass. +ALTER TABLE public.api_keys + DROP CONSTRAINT IF EXISTS api_keys_sod_ack_paired; +ALTER TABLE public.api_keys + ADD CONSTRAINT api_keys_sod_ack_paired + CHECK ((sod_acknowledged_at IS NULL) = (sod_acknowledged_by IS NULL)); + +-- Grandfather agent:write onto existing non-revoked keys that already carry an +-- explicit scope list. Keys with NULL scopes are intentionally left NULL — +-- pinning them to a materialized list would freeze their dynamic +-- DEFAULT_SCOPES fallback semantics. NOTE the trade-off: validateApiKey +-- resolves NULL scopes to DEFAULT_SCOPES (which deliberately excludes +-- agent:write), so an active NULL-scope key that called the previously +-- unscoped memory tools WILL lose that ability. The WARNING below makes any +-- such keys visible at apply time; if it fires on a live environment, decide +-- per key (re-mint with explicit scopes, or accept the tightening — memory +-- writes were never an intended legacy-key capability). +DO $$ +DECLARE + v_null_scope_keys integer; +BEGIN + SELECT count(*) INTO v_null_scope_keys + FROM public.api_keys + WHERE revoked_at IS NULL AND scopes IS NULL; + + IF v_null_scope_keys > 0 THEN + RAISE WARNING + 'agent:write grandfathering skipped % active NULL-scope key(s). These fall back to DEFAULT_SCOPES (no agent:write) and lose access to gnubok_remember_fact/gnubok_forget_fact. Review them: SELECT id, name, created_at FROM api_keys WHERE revoked_at IS NULL AND scopes IS NULL;', + v_null_scope_keys; + END IF; +END +$$; + +UPDATE public.api_keys +SET scopes = array_append(scopes, 'agent:write') +WHERE revoked_at IS NULL + AND scopes IS NOT NULL + AND NOT ('agent:write' = ANY(scopes)); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/api-keys-sod-ack-and-agent-write.pg.test.ts b/tests/pg/api-keys-sod-ack-and-agent-write.pg.test.ts new file mode 100644 index 00000000..754e4e61 --- /dev/null +++ b/tests/pg/api-keys-sod-ack-and-agent-write.pg.test.ts @@ -0,0 +1,193 @@ +import { randomUUID } from 'crypto' +import { describe, expect, it } from 'vitest' +import { seedCompany } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * pg-real coverage for migration 20260619140000_api_keys_sod_ack_and_agent_write. + * + * Locks in: + * - The two SoD-acknowledgement columns exist with the expected types and + * the sod_acknowledged_by FK targets auth.users(id). + * - The grandfather UPDATE shipped agent:write onto every non-revoked key + * that already carried an explicit scope list (idempotent — running it + * again adds no duplicate). + * - NULL-scopes (legacy full/default access) and revoked keys were left + * untouched. + * + * Inserts go through the pool (superuser, RLS-bypassing) — this is a schema / + * data-migration smoke, not an RLS test. + */ + +async function insertApiKey(params: { + userId: string + companyId: string + scopes: string[] | null + revoked?: boolean +}): Promise { + const id = randomUUID() + const hash = randomUUID().replace(/-/g, '').padEnd(64, '0') + await getPool().query( + `INSERT INTO public.api_keys + (id, user_id, company_id, key_hash, key_prefix, name, scopes, revoked_at) + VALUES ($1, $2, $3, $4, 'gnubok_sk_test', 'pg-real key', $5, $6)`, + [ + id, + params.userId, + params.companyId, + hash, + params.scopes, + params.revoked ? new Date() : null, + ], + ) + return id +} + +async function scopesOf(id: string): Promise { + const { rows } = await getPool().query<{ scopes: string[] | null }>( + `SELECT scopes FROM public.api_keys WHERE id = $1`, + [id], + ) + return rows[0]?.scopes ?? null +} + +describe('api_keys SoD-ack columns + agent:write grandfathering', () => { + it('exposes sod_acknowledged_at / sod_acknowledged_by with the expected shape', async () => { + const { rows } = await getPool().query<{ + column_name: string + data_type: string + }>( + `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'api_keys' + AND column_name IN ('sod_acknowledged_at', 'sod_acknowledged_by') + ORDER BY column_name`, + ) + const byName = Object.fromEntries(rows.map((r) => [r.column_name, r.data_type])) + expect(byName['sod_acknowledged_at']).toBe('timestamp with time zone') + expect(byName['sod_acknowledged_by']).toBe('uuid') + }) + + it('sod_acknowledged_by references auth.users(id)', async () => { + // pg_constraint, not information_schema: the constraint crosses schemas + // (public → auth) and information_schema's constraint_column_usage hides + // referenced tables outside the constrained table's schema. + const { rows } = await getPool().query<{ foreign_table: string }>( + `SELECT c.confrelid::regclass::text AS foreign_table + FROM pg_constraint c + JOIN pg_attribute a + ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'public.api_keys'::regclass + AND c.contype = 'f' + AND a.attname = 'sod_acknowledged_by'`, + ) + expect(rows[0]?.foreign_table).toBe('auth.users') + }) + + it('accepts a write that records the SoD acknowledgement', async () => { + const { userId, companyId } = await seedCompany() + const keyId = await insertApiKey({ + userId, + companyId, + scopes: ['invoices:write', 'pending_operations:approve'], + }) + + await getPool().query( + `UPDATE public.api_keys + SET sod_acknowledged_at = now(), sod_acknowledged_by = $2 + WHERE id = $1`, + [keyId, userId], + ) + + const { rows } = await getPool().query<{ + sod_acknowledged_at: string | null + sod_acknowledged_by: string | null + }>( + `SELECT sod_acknowledged_at, sod_acknowledged_by + FROM public.api_keys WHERE id = $1`, + [keyId], + ) + expect(rows[0]?.sod_acknowledged_at).not.toBeNull() + expect(rows[0]?.sod_acknowledged_by).toBe(userId) + }) + + it('rejects a partial SoD acknowledgement (paired-NULL CHECK)', async () => { + const { userId, companyId } = await seedCompany() + const keyId = await insertApiKey({ + userId, + companyId, + scopes: ['invoices:write', 'pending_operations:approve'], + }) + + // Timestamp without acknowledger — the audit pair must be both-or-neither. + await expect( + getPool().query( + `UPDATE public.api_keys SET sod_acknowledged_at = now() WHERE id = $1`, + [keyId], + ), + ).rejects.toMatchObject({ code: '23514' }) // check_violation + + // Acknowledger without timestamp — equally rejected. + await expect( + getPool().query( + `UPDATE public.api_keys SET sod_acknowledged_by = $2 WHERE id = $1`, + [keyId, userId], + ), + ).rejects.toMatchObject({ code: '23514' }) + }) + + it('grandfather UPDATE adds agent:write to scoped, non-revoked keys (idempotent)', async () => { + const { userId, companyId } = await seedCompany() + // Mimic a pre-migration key created with an explicit scope list but WITHOUT + // agent:write (the column existed before this migration). + const keyId = await insertApiKey({ + userId, + companyId, + scopes: ['transactions:read', 'reports:read'], + }) + + // Re-run the migration's grandfather statement; it must be idempotent. + const run = () => + getPool().query( + `UPDATE public.api_keys + SET scopes = array_append(scopes, 'agent:write') + WHERE id = $1 + AND revoked_at IS NULL + AND scopes IS NOT NULL + AND NOT ('agent:write' = ANY(scopes))`, + [keyId], + ) + await run() + await run() + + const scopes = await scopesOf(keyId) + expect(scopes).toContain('agent:write') + expect(scopes?.filter((s) => s === 'agent:write')).toHaveLength(1) + }) + + it('leaves NULL-scopes and revoked keys untouched', async () => { + const { userId, companyId } = await seedCompany() + const nullKey = await insertApiKey({ userId, companyId, scopes: null }) + const revokedKey = await insertApiKey({ + userId, + companyId, + scopes: ['transactions:read'], + revoked: true, + }) + + await getPool().query( + `UPDATE public.api_keys + SET scopes = array_append(scopes, 'agent:write') + WHERE id = ANY($1::uuid[]) + AND revoked_at IS NULL + AND scopes IS NOT NULL + AND NOT ('agent:write' = ANY(scopes))`, + [[nullKey, revokedKey]], + ) + + expect(await scopesOf(nullKey)).toBeNull() + expect(await scopesOf(revokedKey)).toEqual(['transactions:read']) + }) +})