Files
accounted/supabase/migrations/20260831111519_api_key_unattended_commit_limit.sql
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

142 lines
6.0 KiB
PL/PgSQL

-- Approval-authority limit per API key: what an agent may finish WITHOUT a human.
--
-- This is deliberately NOT a write limit and deliberately NOT enforced inside
-- commit_journal_entry. Over the ceiling the agent may still stage a
-- pending_operation; it just may not commit it unattended, so a human approves
-- it in /pending. Nothing is destroyed, no voucher number is burned, and no
-- löpnummer gap is created (BFL 5 kap. 7 §).
--
-- ## Why the limit lives on api_keys and never on company_settings
--
-- company_settings.agent_auto_commit_max_amount existed once
-- (20260501120000) and was dropped four days later (20260505190027). A
-- company-scoped money threshold catches HUMANS too, which is the opposite of
-- the intent. Keyed on the credential, the check can only ever fire for an
-- agent: the gate reads actor.type = 'api_key' plus a non-null limit on that
-- key, and human approvals ({type:'user'}), cron, and every cookie-session
-- route commit with a different actor or none at all.
--
-- ## Why validate_and_increment_api_key returns it
--
-- That function is the one place the database itself verifies which credential
-- is acting: it matches the key hash. A limit returned from there is bound to a
-- DB-verified key rather than asserted by the caller. Passing a key id into
-- commit_journal_entry would have been caller-asserted and unverifiable (the
-- pending-op path runs as service_role, where the tenant guard bypasses by
-- design), while costing a DROP+CREATE on the function that issues every
-- voucher number. Two of that function's seven redefinitions were emergency
-- fixes for PostgREST overload ambiguity; there is no upside left to pay that.
--
-- pg-test: tests/pg/api-key-unattended-commit-limit.pg.test.ts
ALTER TABLE public.api_keys
ADD COLUMN IF NOT EXISTS unattended_commit_limit numeric(14, 2);
-- The single most important line in this migration.
--
-- A stored 0 would block every commit for that key, and NULL-read-as-0 is the
-- catastrophic failure mode of the whole feature. Making zero unrepresentable
-- at the storage layer means that state cannot be reached even by a bad UI
-- write, a bad PATCH body, or a bad backfill. Absence of a limit is expressed
-- ONLY as NULL, never as 0.
ALTER TABLE public.api_keys
DROP CONSTRAINT IF EXISTS api_keys_unattended_commit_limit_positive;
-- Added VALIDATED, not NOT VALID: api_keys is 388 rows / 768 kB in production,
-- so the validating scan is sub-millisecond. The NOT VALID + VALIDATE dance
-- buys nothing at this size and can leave the constraint permanently unenforced
-- if the second statement is ever skipped.
ALTER TABLE public.api_keys
ADD CONSTRAINT api_keys_unattended_commit_limit_positive
CHECK (unattended_commit_limit IS NULL OR unattended_commit_limit > 0);
COMMENT ON COLUMN public.api_keys.unattended_commit_limit IS
'SEK ceiling on what this key may commit without human approval. NULL = no limit, and NULL is the only representation of "no limit": zero is forbidden by CHECK because a stored 0 would block every commit. Over the ceiling the agent still stages the operation for a human to approve.';
-- Surface the limit on the one call that already verifies the credential.
--
-- The body below is copied VERBATIM from 20260621130000_api_keys_rotation_grace.sql
-- (the latest definition, which added previous_key_hash rotation grace) with
-- exactly two changes: unattended_commit_limit joins the RETURNS TABLE, and it
-- is selected into a variable and returned in all three RETURN QUERY branches.
-- Copying an older body would silently revert key rotation and lock out every
-- rotating integration.
DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text);
CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
RETURNS TABLE(
user_id uuid,
company_id uuid,
api_key_id uuid,
api_key_name text,
rate_limited boolean,
scopes text[],
mode text,
unattended_commit_limit numeric
)
LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_id uuid;
v_user_id uuid;
v_company_id uuid;
v_api_key_name text;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
v_scopes text[];
v_mode text;
v_unattended_commit_limit numeric;
BEGIN
-- Match the live key_hash, OR a previous (just-rotated) key_hash that is still
-- inside its grace window. Both gated by revoked_at IS NULL.
SELECT ak.id, ak.user_id, ak.company_id, ak.name,
ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes, ak.mode,
ak.unattended_commit_limit
INTO v_id, v_user_id, v_company_id, v_api_key_name,
v_rate_limit_rpm, v_request_count, v_window_start, v_scopes, v_mode,
v_unattended_commit_limit
FROM public.api_keys ak
WHERE ak.revoked_at IS NULL
AND (
ak.key_hash = p_key_hash
OR (
ak.previous_key_hash = p_key_hash
AND ak.previous_key_expires_at IS NOT NULL
AND ak.previous_key_expires_at > now()
)
)
FOR UPDATE;
IF v_id IS NULL THEN
RETURN; -- no live match (incl. expired grace) → caller returns 401, as before
END IF;
-- Reset the rate-limit window if it is unset or older than one minute.
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.api_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_used_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode,
v_unattended_commit_limit;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, true, v_scopes, v_mode,
v_unattended_commit_limit;
RETURN;
END IF;
UPDATE public.api_keys
SET request_count = request_count + 1,
last_used_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode,
v_unattended_commit_limit;
END;
$$;
NOTIFY pgrst, 'reload schema';