feat(auth): SoD acknowledge on stage+approve keys + agent:write scope for memory tools (P0-3) (#681)

* feat(auth): SoD acknowledge on stage+approve keys + agent:write scope for memory tools

Segregation of duties on API keys is now warn + explicit acknowledgement
(not block): minting a key with any staging write scope AND
pending_operations:approve returns 409 API_KEY_SOD_CONFLICT unless the
caller re-POSTs with acknowledge_sod: true. The acknowledgement is recorded
(sod_acknowledged_at / sod_acknowledged_by) for an auditable risk acceptance
(ISO 27001:2022 A.5.3 / BFNAR 2013:2). The create UI surfaces an inline
warning and an explicit confirm dialog before submitting the ack — the
default "all scopes ticked" create routes through that path.

Also introduces the agent:write scope and maps the previously-UNMAPPED memory
tools gnubok_remember_fact / gnubok_forget_fact to it. Because unmapped tools
were callable by any key, the migration grandfathers agent:write onto every
existing non-revoked key with an explicit scope list so nothing regresses;
new keys must opt in. agent:write is deliberately excluded from the default
grants and is NOT a staging scope (no SoD conflict with approve).

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

* fix(db): enforce both-or-neither on the SoD acknowledgement pair

Review finding (Greptile P2): sod_acknowledged_at/sod_acknowledged_by were
independently nullable, so a partial write could silently pass and undermine
the auditable risk acceptance (ISO 27001 A.5.3 / SOC 2 CC6.1). Adds a
paired-NULL CHECK constraint + pg-real coverage for both partial-write
directions.

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

* docs(auth)+feat(auth): compliance-review round — self-attestation documented, ack logged, SoD boundary assumption captured

- Migration header now states explicitly that the SoD acknowledgement is a
  SELF-attestation by deliberate design (enskild firma has no second person;
  the claude.ai approval flow needs stage+approve on one credential) — the
  control objective is informed consent + audit record, not dual control.
- The acknowledge_sod=true path now emits a structured log.warn
  (api_key.sod_acknowledged with key id/prefix, conflicting scope, scopes,
  acknowledger, company) so the acceptance lands in the logging pipeline in
  addition to the sod_acknowledged_* columns (ASVS V16.1.1).
- STAGING_SCOPES carries the documented system control (BFNAR 2013:2
  systemdokumentation) for why agent:write is not a staging scope: memory
  tools write advisory agent context and cannot stage räkenskapsinformation.

Dismissed as by-design/verified: hard-block and second-approver remediations
(user decision: warn + acknowledge); scope-update gap (the [id] route only
supports DELETE — scopes are immutable post-creation); session-auth concern
(withRouteContext is cookie+MFA only; API-key auth exists only on /api/v1
and MCP).

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

* chore: re-trigger CI (Supabase Preview 502 infra hiccup)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-06 11:13:35 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 305f469fc3
commit 0bc81d4c88
11 changed files with 688 additions and 5 deletions
@@ -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<string, unknown> = {}
const handler: ProxyHandler<object> = {
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<string, unknown>
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<string, unknown>
expect(payload).not.toHaveProperty('sod_acknowledged_at')
expect(payload).not.toHaveProperty('sod_acknowledged_by')
expect(payload.scopes).toEqual(['reports:read'])
})
})
+42 -1
View File
@@ -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,
+46 -2
View File
@@ -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<ApiKey[]>([])
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() {
</div>
))}
</div>
{hasSodConflict && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="leading-snug">{t('sod_warning')}</p>
</div>
)}
</div>
</div>
<DialogFooter>
@@ -574,6 +617,7 @@ export function ApiKeysPanel() {
</Dialog>
<DestructiveConfirmDialog {...revokeDialogProps} />
<DestructiveConfirmDialog {...sodDialogProps} />
{/* Show key once dialog */}
<Dialog open={showKeyDialog} onOpenChange={(open) => {
@@ -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)
})
})
+68
View File
@@ -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
// ============================================================
+16
View File
@@ -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<string, ApiKeyScope> = {
// 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',
+11
View File
@@ -1742,6 +1742,17 @@ const API_KEY: Record<string, StructuredErrorEntry> = {
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.',
},
},
}
// ─────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
@@ -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';
@@ -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<string> {
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<string[] | null> {
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'])
})
})