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:
co-authored by
Claude Opus 4.8
parent
305f469fc3
commit
0bc81d4c88
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user