0bc81d4c88
* 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>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('@supabase/supabase-js', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
import {
|
|
generateApiKey,
|
|
hashApiKey,
|
|
extractBearerToken,
|
|
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'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ============================================================
|
|
// generateApiKey
|
|
// ============================================================
|
|
|
|
describe('generateApiKey', () => {
|
|
it('returns key starting with "gnubok_sk_"', () => {
|
|
const { key } = generateApiKey()
|
|
expect(key.startsWith('gnubok_sk_')).toBe(true)
|
|
})
|
|
|
|
it('returns 64-char hex SHA-256 hash', () => {
|
|
const { hash } = generateApiKey()
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
it('returns prefix of KEY_PREFIX + 8 chars', () => {
|
|
const { key, prefix } = generateApiKey()
|
|
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
|
|
})
|
|
|
|
it('generates unique keys on successive calls', () => {
|
|
const a = generateApiKey()
|
|
const b = generateApiKey()
|
|
expect(a.key).not.toBe(b.key)
|
|
expect(a.hash).not.toBe(b.hash)
|
|
})
|
|
|
|
it('hash matches hashApiKey(key)', () => {
|
|
const { key, hash } = generateApiKey()
|
|
expect(hashApiKey(key)).toBe(hash)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// hashApiKey
|
|
// ============================================================
|
|
|
|
describe('hashApiKey', () => {
|
|
it('returns 64-char hex string', () => {
|
|
const hash = hashApiKey('gnubok_sk_test-key')
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
it('is deterministic for same input', () => {
|
|
const hash1 = hashApiKey('gnubok_sk_deterministic')
|
|
const hash2 = hashApiKey('gnubok_sk_deterministic')
|
|
expect(hash1).toBe(hash2)
|
|
})
|
|
|
|
it('produces different hashes for different inputs', () => {
|
|
const hash1 = hashApiKey('gnubok_sk_key-a')
|
|
const hash2 = hashApiKey('gnubok_sk_key-b')
|
|
expect(hash1).not.toBe(hash2)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// extractBearerToken
|
|
// ============================================================
|
|
|
|
describe('extractBearerToken', () => {
|
|
it('extracts token from valid Bearer header', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Bearer my-secret-token' },
|
|
})
|
|
expect(extractBearerToken(request)).toBe('my-secret-token')
|
|
})
|
|
|
|
it('returns null when no authorization header', () => {
|
|
const request = new Request('http://localhost')
|
|
expect(extractBearerToken(request)).toBeNull()
|
|
})
|
|
|
|
it('returns null when header is not Bearer scheme', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Basic dXNlcjpwYXNz' },
|
|
})
|
|
expect(extractBearerToken(request)).toBeNull()
|
|
})
|
|
|
|
it('handles token with special characters', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
|
|
})
|
|
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// validateScopes
|
|
// ============================================================
|
|
|
|
describe('validateScopes', () => {
|
|
it('returns null for null input', () => {
|
|
expect(validateScopes(null)).toBeNull()
|
|
})
|
|
|
|
it('returns null for undefined input', () => {
|
|
expect(validateScopes(undefined)).toBeNull()
|
|
})
|
|
|
|
it('returns null for non-array input', () => {
|
|
expect(validateScopes('transactions:read')).toBeNull()
|
|
expect(validateScopes(42)).toBeNull()
|
|
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
|
|
})
|
|
|
|
it('filters to only valid API_KEY_SCOPES', () => {
|
|
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
|
|
expect(result).toEqual(['transactions:read', 'reports:read'])
|
|
})
|
|
|
|
it('returns null when no valid scopes remain after filter', () => {
|
|
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
|
|
})
|
|
|
|
it('preserves valid scopes from mixed input', () => {
|
|
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
|
|
expect(result).toEqual(['customers:write', 'invoices:read'])
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// hasScope
|
|
// ============================================================
|
|
|
|
describe('hasScope', () => {
|
|
it('returns true when scope present in array', () => {
|
|
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
|
|
})
|
|
|
|
it('returns false when scope absent', () => {
|
|
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// 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
|
|
// ============================================================
|
|
|
|
describe('validateApiKey', () => {
|
|
function setupMockRpc(response: { data: unknown; error: unknown }) {
|
|
const mockRpc = vi.fn().mockResolvedValue(response)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
|
|
}
|
|
|
|
it('rejects keys not starting with "gnubok_sk_"', async () => {
|
|
const result = await validateApiKey('invalid-key-format')
|
|
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
|
|
})
|
|
|
|
it('rejects a refresh token presented as Bearer with a specific message', async () => {
|
|
const result = await validateApiKey('gnubok_rt_some_refresh_token')
|
|
expect('status' in result && result.status).toBe(401)
|
|
expect('error' in result && result.error).toContain('Refresh token')
|
|
})
|
|
|
|
it('rejects when RPC returns error', async () => {
|
|
setupMockRpc({ data: null, error: { message: 'db error' } })
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
|
|
})
|
|
|
|
it('rejects when RPC returns empty data array', async () => {
|
|
setupMockRpc({ data: [], error: null })
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
|
|
})
|
|
|
|
it('returns rate limit error when rate_limited is true', async () => {
|
|
setupMockRpc({
|
|
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
|
|
})
|
|
|
|
it('returns userId, companyId, scopes on success', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
scopes: ['transactions:read', 'reports:read'],
|
|
rate_limited: false,
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: undefined,
|
|
apiKeyName: undefined,
|
|
scopes: ['transactions:read', 'reports:read'],
|
|
mode: 'live',
|
|
})
|
|
})
|
|
|
|
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
scopes: null,
|
|
rate_limited: false,
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: undefined,
|
|
apiKeyName: undefined,
|
|
scopes: DEFAULT_SCOPES,
|
|
mode: 'live',
|
|
})
|
|
})
|
|
|
|
it('surfaces mode from the RPC row', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
api_key_id: 'ak_1',
|
|
api_key_name: 'CI test key',
|
|
scopes: ['transactions:read'],
|
|
rate_limited: false,
|
|
mode: 'test',
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: 'ak_1',
|
|
apiKeyName: 'CI test key',
|
|
scopes: ['transactions:read'],
|
|
mode: 'test',
|
|
})
|
|
})
|
|
})
|