Å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.
142 lines
6.0 KiB
PL/PgSQL
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';
|