9fe37b85b5
* feat(agents): per-key approval authority, the amount an agent may post unattended An API key gets an optional ceiling in SEK. Above it the agent may still stage the work, it just may not finish it alone: a human approves the same verifikat in the app. Default is NULL, so every existing key keeps its behaviour and turning this on is entirely opt-in. Enforced at the two places an API key reaches the ledger, and at both the refusal happens BEFORE the point of no return: - MCP: in commitPendingOperation, before the atomic claim, so the operation stays 'pending'. Behind the claim it would be caught by the generic handler, marked terminal 'rejected', and the staged verifikat would be gone. - REST: in journal-entries.commit, before commitEntry, so the draft stays a draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run refuses too, rather than promising a voucher number the key cannot deliver. Not enforced inside commit_journal_entry: a RAISE there is swallowed by engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function that issues every voucher number. Operations whose amount is only known during dispatch (batch allocation, bulk booking, the settlement link paths) fail OPEN behind an explicit allowlist. Pricing them ahead of dispatch would be a guess, and a wrong guess silently breaks batch allocation the day someone sets a limit. The allowlist is derived from what production actually stores: create_voucher carries total_debit on 1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003, create_supplier_invoice_from_inbox carries total on 208 of 228. This is a blast-radius cap, not a security boundary. A per-entry ceiling is defeated by splitting one entry into several, and an LLM will find that, so UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the primitive that actually bounds exposure and is left to a separate change. The guard is written NULL-first everywhere. An absent, unparseable or non-positive ceiling always means unlimited, never "block everything". Agents read their own ceiling from gnubok_get_agent_briefing instead of discovering it by burning a staged verifikat on a 403. Changing a ceiling is auditable: it now renders in behandlingshistorik (BFL 5 kap. 11 §). The audit trigger already fired on the column, but the report dropped the event because the field was not in its diff map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(skill): regenerate accounted-api skill for the new commit pitfall apiskill:check is a ratchet: the generated reference must match the endpoint registry. Never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(agents): pin the DB default itself, and declare the briefing field required Two review findings, both real: - the default test stored an explicit NULL, so it stayed green even if the column default changed to a positive ceiling: the one change that would silently start blocking every existing key. It now omits the column. - gnubok_get_agent_briefing documents unattended_commit_limit as always present and emits it unconditionally, so it belongs in the output schema's required list. Declined the NOT VALID constraint suggestion, with the reason recorded in the migration: api_keys is 388 rows / 768 kB in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): name the TOCTOU window in the REST ceiling check A security scan flagged that the line sum is read before commitEntry, so a concurrent write to the draft's lines can post over the ceiling. Real, and accepted: closing it means enforcing inside commit_journal_entry, where a RAISE becomes a retryable 500 and destroys the staged operation on the MCP path. Recorded in the code rather than left implicit, so nobody later mistakes this for a hard control. A per-entry ceiling is already defeated by splitting, which needs no race; the cumulative rolling-window limit is the primitive that bounds exposure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): price the settlement and batch paths that were bypassing the ceiling A security scan flagged that known money-posting operations fail open, and it was right. The first cut priced only create_voucher, categorize_transaction and create_supplier_invoice_from_inbox, on the belief that the batch and settlement paths computed their totals only inside SQL at dispatch. Production says otherwise: the staged preview already carries the amount, because it is the number a human is shown when approving the operation. Over the last 120 days each of these is present and numeric on 100% of that type's staged rows: link_transaction_journal_entry transaction_amount 1369 rows bulk_book_transactions tx_sum 273 rows link_supplier_invoice_voucher payment_amount 55 rows match_batch_allocate total_allocated 24 rows mark_invoice_paid total 3 rows So a key with a ceiling could post any amount through the four largest settlement paths. Now priced, and the ceiling applies. Only reconciliation_match stays unpriced: it carries pair_count, which is a COUNT. Pricing off that would compare pairs against kronor, which is worse than not enforcing. link_document_to_voucher and attach_document_to_transaction move no money at all; the transaction_amount they carry is context, not a posting. Genuinely unpriceable types still fail OPEN. This control can only ever narrow what a key does, and a wrong guess at an amount blocks a legitimate commit, so guessing high would leave an agent unable to work. Adds a test that walks the whole allowlist, so a typo'd field name cannot silently make a type unpriceable again: that is exactly the hole this closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room The tools/list context-budget bench sits at 65 000 tokens and main now leaves roughly 20 tokens of headroom. An always-present field on the briefing's output schema costs about 85, so this addition alone pushed the bench red. The bench's own note is explicit that the answer is to demote a tool rather than raise the ceiling, so raising it here would be the wrong trade for a nice-to-have. Nothing is lost that matters: the operation is never destroyed when it is refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs one round trip and no work. That error already carries both attempted and limit, and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing is worth doing once there is budget to spend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(api): spell affärshändelse correctly in the commit pitfall Fixed in the route's registerEndpoint pitfalls, which is the source; the skill reference is regenerated from it and never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
// Not lib/company/context: that module imports next/headers for the legacy
|
|
// company cookie, and this file is reachable from bundles where that import
|
|
// is a build error.
|
|
import { getActiveCompanyId } from '@/lib/company/active-company'
|
|
|
|
const KEY_PREFIX = 'gnubok_sk_'
|
|
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
|
|
|
// ── API Key Scopes ──────────────────────────────────────────
|
|
// The catalogue lives in scope-catalog.ts (no server imports, safe for client
|
|
// bundles) and is re-exported here so existing imports keep working.
|
|
export {
|
|
API_KEY_SCOPES,
|
|
ALL_SCOPES,
|
|
DEFAULT_SCOPES,
|
|
DEFAULT_OAUTH_SCOPES,
|
|
PUBLIC_OAUTH_METADATA_SCOPES,
|
|
STAGING_SCOPES,
|
|
findStageApproveConflict,
|
|
SCOPE_GROUPS,
|
|
scopeKind,
|
|
TOOL_SCOPE_MAP,
|
|
TOOL_COUNT_BY_SCOPE,
|
|
} from './scope-catalog'
|
|
export type { ApiKeyScope, ScopeGroup } from './scope-catalog'
|
|
import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog'
|
|
|
|
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
|
if (scopes === null || scopes === undefined) return null
|
|
if (!Array.isArray(scopes)) return null
|
|
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
|
|
return valid.length > 0 ? valid : null
|
|
}
|
|
|
|
/**
|
|
* Create a Supabase service client that doesn't require cookies.
|
|
* Used for API key validation (MCP, webhooks) where there's no browser session.
|
|
*/
|
|
export function createServiceClientNoCookies() {
|
|
return createServiceRoleClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
|
|
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
// Test keys carry an explicit `test_` infix so integrators can tell at a
|
|
// glance which environment a key targets (matches the llms.txt contract:
|
|
// `gnubok_sk_test_<random>`). The infix is purely cosmetic: the authoritative
|
|
// mode is the `mode` column on api_keys, read back by hash in validateApiKey,
|
|
// so nothing trusts the key string. Both variants keep the `gnubok_sk_`
|
|
// prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds.
|
|
const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}`
|
|
const hash = hashApiKey(key)
|
|
// First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx'
|
|
// for live: the stored prefix is what the settings UI shows, so the test_
|
|
// infix is visible in the key list without exposing the secret.
|
|
const prefix = key.slice(0, KEY_PREFIX.length + 8)
|
|
return { key, hash, prefix }
|
|
}
|
|
|
|
/**
|
|
* SHA-256, deliberately, and NOT a slow KDF like bcrypt/argon2.
|
|
*
|
|
* CodeQL flags this as js/insufficient-password-hash. That rule exists for
|
|
* user-chosen passwords, which are low-entropy and brute-forceable, so the
|
|
* defence is to make each guess expensive. This input is not a password: keys
|
|
* come from generateApiKey as 32 CSPRNG bytes (`gnubok_sk_<base64url>`), and no
|
|
* work factor moves the needle on a 256-bit random secret.
|
|
*
|
|
* A slow KDF would also be actively worse here: this runs on the hot path of
|
|
* every MCP request, where the hash is the primary-key lookup used to find the
|
|
* row, so per-request cost is real latency for zero security gain.
|
|
*
|
|
* Do NOT "fix" this by changing the algorithm. The hash IS the stored
|
|
* credential, so a different function invalidates every live `gnubok_sk_` key,
|
|
* breaking existing MCP connections with no migration path.
|
|
*/
|
|
export function hashApiKey(key: string): string {
|
|
return crypto.createHash('sha256').update(key).digest('hex')
|
|
}
|
|
|
|
export function generateRefreshToken(): { token: string; hash: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
const token = `${REFRESH_TOKEN_PREFIX}${random}`
|
|
const hash = crypto.createHash('sha256').update(token).digest('hex')
|
|
return { token, hash }
|
|
}
|
|
|
|
export function hashRefreshToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
export function isRefreshToken(token: string): boolean {
|
|
return token.startsWith(REFRESH_TOKEN_PREFIX)
|
|
}
|
|
|
|
export function extractBearerToken(request: Request): string | null {
|
|
const authHeader = request.headers.get('authorization')
|
|
if (!authHeader?.startsWith('Bearer ')) return null
|
|
return authHeader.slice(7)
|
|
}
|
|
|
|
/**
|
|
* Validate an API key and enforce rate limiting.
|
|
* Uses the DB RPC for atomic check + increment.
|
|
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
|
|
* success, or an error with HTTP status.
|
|
* null scopes in DB → DEFAULT_SCOPES (read-only).
|
|
*
|
|
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
|
|
* can record actor attribution on pending_operations and audit_log.
|
|
* They may be undefined when the deployed DB hasn't yet run the migration
|
|
* that adds them to the RPC return shape.
|
|
*/
|
|
/**
|
|
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
|
|
* are bound to deterministic sandbox companies. Keys created before the Phase 1
|
|
* migration default to 'live' for backwards compatibility.
|
|
*/
|
|
export type ApiKeyMode = 'live' | 'test'
|
|
|
|
/**
|
|
* Seconds a rate-limited caller should wait before retrying.
|
|
*
|
|
* `validate_and_increment_api_key` enforces a FIXED one-minute tumbling
|
|
* window per key row (`rate_limit_window_start`), and the limited branch
|
|
* deliberately does not slide the window, so the current window can never
|
|
* have more than 60 seconds left to run. 60 is therefore an exact upper
|
|
* bound rather than a guess, which is what `Retry-After` requires.
|
|
*
|
|
* The exact reset instant is `rate_limit_window_start + 1 minute` and is
|
|
* known inside the RPC, but its RETURNS TABLE carries no window column, so
|
|
* TypeScript cannot see it. Emitting the IETF `RateLimit` / `RateLimit-Policy`
|
|
* fields (draft-ietf-httpapi-ratelimit-headers) needs that column first.
|
|
*/
|
|
export const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
|
|
|
export async function validateApiKey(
|
|
key: string
|
|
): Promise<
|
|
| {
|
|
userId: string
|
|
/**
|
|
* The key's default company. null only while the key's user has no
|
|
* company at all (minted from the OAuth popup before onboarding, issue
|
|
* #1814): the first validation after a company exists binds the key.
|
|
*/
|
|
companyId: string | null
|
|
apiKeyId?: string
|
|
apiKeyName?: string
|
|
scopes: ApiKeyScope[]
|
|
mode: ApiKeyMode
|
|
/**
|
|
* SEK ceiling on what this key may commit WITHOUT human approval, or
|
|
* null for no limit. Null is the only representation of "no limit": a
|
|
* stored 0 is forbidden by CHECK, because reading absence as 0 would
|
|
* block every commit for the key.
|
|
*/
|
|
unattendedCommitLimit: number | null
|
|
}
|
|
| { error: string; status: number }
|
|
> {
|
|
if (isRefreshToken(key)) {
|
|
return {
|
|
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
|
|
status: 401,
|
|
}
|
|
}
|
|
|
|
if (!key.startsWith(KEY_PREFIX)) {
|
|
return { error: 'Invalid API key format', status: 401 }
|
|
}
|
|
|
|
const hash = hashApiKey(key)
|
|
const supabase = createServiceClientNoCookies()
|
|
|
|
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
|
|
p_key_hash: hash,
|
|
})
|
|
|
|
if (error || !data || data.length === 0) {
|
|
return { error: 'Invalid API key', status: 401 }
|
|
}
|
|
|
|
const row = data[0]
|
|
|
|
if (row.rate_limited) {
|
|
return { error: 'Rate limit exceeded', status: 429 }
|
|
}
|
|
|
|
const companyId: string | null =
|
|
row.company_id ?? (await bindUnboundKey(supabase, row.user_id, row.api_key_id))
|
|
|
|
return {
|
|
userId: row.user_id,
|
|
companyId,
|
|
apiKeyId: row.api_key_id,
|
|
apiKeyName: row.api_key_name,
|
|
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
|
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
|
|
// migration that adds it to the RPC return. Default to 'live' so existing
|
|
// keys behave unchanged.
|
|
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
|
|
// PostgREST returns numeric as a STRING, so parse explicitly rather than
|
|
// relying on `>` coercion at the comparison site. Anything unparseable,
|
|
// absent (a DB that has not run the migration), or non-positive becomes
|
|
// null, i.e. no limit: this control must fail OPEN. Blocking a company's
|
|
// month-end because a defence-in-depth read blipped would be far worse
|
|
// than not enforcing.
|
|
unattendedCommitLimit: parseUnattendedCommitLimit(row.unattended_commit_limit),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Null unless the value is a finite number strictly greater than zero.
|
|
*
|
|
* Written NULL-first on purpose. Never `?? 0`, never `|| 0`, never
|
|
* `Number(undefined)` (which yields NaN, and NaN comparisons are false, so it
|
|
* would silently disable the control rather than loudly fail).
|
|
*/
|
|
function parseUnattendedCommitLimit(value: unknown): number | null {
|
|
if (value === null || value === undefined) return null
|
|
const parsed = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
return parsed
|
|
}
|
|
|
|
/**
|
|
* Late binding for keys minted before the user's first company existed.
|
|
*
|
|
* The OAuth token endpoint stores company_id NULL for such keys. Company
|
|
* creation happens in the web app (a Server Action) which knows nothing about
|
|
* the user's keys, so the binding is healed here, on the first validation after
|
|
* a company exists: one place, regardless of how the company was created.
|
|
* Returns null while the user still has no company. The UPDATE is best-effort:
|
|
* a failed write only means the next call resolves again.
|
|
*/
|
|
async function bindUnboundKey(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
apiKeyId: string | undefined
|
|
): Promise<string | null> {
|
|
let companyId: string | null
|
|
try {
|
|
companyId = await getActiveCompanyId(supabase, userId)
|
|
} catch {
|
|
return null
|
|
}
|
|
if (!companyId) return null
|
|
if (apiKeyId) {
|
|
await supabase
|
|
.from('api_keys')
|
|
.update({ company_id: companyId })
|
|
.eq('id', apiKeyId)
|
|
.is('company_id', null)
|
|
}
|
|
return companyId
|
|
}
|
|
|
|
/**
|
|
* Check if a given scope is allowed by the key's scopes.
|
|
*/
|
|
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
|
|
return keyScopes.includes(required)
|
|
}
|