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
+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.',
},
},
}
// ─────────────────────────────────────────────────────────────────