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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user