fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)

* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:38:30 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6e8d76a9cb
commit 18cbc4c30a
92 changed files with 10355 additions and 657 deletions
@@ -0,0 +1,239 @@
-- Security audit 2026-09-01, critical items (report "Accounted Security Audit").
-- pg-test: tests/pg/security-api-keys-provider-tokens.pg.test.ts
--
-- 1. api_keys identity binding. 20260422120000 replaced the original
-- `auth.uid() = user_id` INSERT check with `user_is_company_admin(company_id)`
-- and dropped the self-binding. Every API-key consumer trusts
-- api_keys.user_id as the acting identity (validate_and_increment_api_key,
-- lib/auth/api-keys.ts, lib/api/v1/with-api-v1.ts, the MCP company
-- routing), so an admin of ANY company could insert a key with a
-- co-member's user_id and act as that user in every company they belong
-- to. INSERT now requires user_id = auth.uid() again, on top of the admin
-- gate; a BEFORE trigger repeats that for JWT sessions and freezes the
-- identity and credential columns against UPDATE from user sessions. The
-- service role (settings routes, OAuth token endpoint, rotation) is
-- unaffected: the trigger is a no-op when the JWT role is not
-- anon/authenticated.
--
-- 2. api_keys SELECT was company-scoped, so every member (including
-- viewers) could read every other member's key_hash and
-- refresh_token_hash. Now: own keys, or company admins.
--
-- 3. rotate_mcp_refresh_token and validate_and_increment_api_key match rows
-- purely by a presented SHA-256 and were executable by `authenticated`.
-- Combined with (2) that made the stored hash a bearer credential: read a
-- colleague's refresh_token_hash, call rotate with your own new key hash,
-- own their connector. Both functions are only ever called through the
-- cookieless service client, so EXECUTE is now service_role only.
--
-- 4. validate_and_increment_api_key fails closed when the key's user is no
-- longer a member of the key's company (offboarded users kept reading a
-- company through MCP resources/read on the key's company_id snapshot).
-- Company-less keys (the OAuth lazy-bind path, 20260826090000) are left
-- alone: they carry no company to check.
--
-- 5. provider_consent_tokens / provider_otc. The DELETE policies from
-- 20260402010000 subselect `company_id FROM team_members`; team_members has
-- no such column, so Postgres binds the name to the outer
-- provider_consents.company_id and the predicate collapses to "the caller
-- has at least one team_members row", which every user has
-- (ensure_user_team). Live check 2026-09-01: an unrelated team member
-- matched 123 of 126 token rows. The 20260415000000 schema sync only
-- recreated the policy IF NOT EXISTS, so the broken one survived. Every
-- code path to these two tables uses the service client
-- (lib/providers/resolve-consent.ts, extensions/general/arcim-migration),
-- so all member policies are dropped and table privileges revoked from
-- anon/authenticated: service role only.
-- 1 + 2: api_keys policies -----------------------------------------------
DROP POLICY IF EXISTS "api_keys_insert" ON public.api_keys;
CREATE POLICY "api_keys_insert" ON public.api_keys
FOR INSERT
WITH CHECK (user_id = auth.uid() AND public.user_is_company_admin(company_id));
DROP POLICY IF EXISTS "api_keys_select" ON public.api_keys;
CREATE POLICY "api_keys_select" ON public.api_keys
FOR SELECT
USING (user_id = auth.uid() OR public.user_is_company_admin(company_id));
CREATE OR REPLACE FUNCTION public.api_keys_guard_jwt_writes()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_jwt_role text := coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
''
);
BEGIN
-- service_role, pg_cron, migrations and the pg-real harness carry no
-- end-user role claim: nothing to enforce.
IF v_jwt_role NOT IN ('anon', 'authenticated') THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'api_keys: user_id must be the calling user'
USING ERRCODE = '42501';
END IF;
IF NEW.refresh_token_hash IS NOT NULL
OR NEW.previous_key_hash IS NOT NULL
OR NEW.previous_refresh_token_hash IS NOT NULL THEN
RAISE EXCEPTION 'api_keys: refresh-token material is set only by the OAuth token endpoint'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.key_hash IS DISTINCT FROM OLD.key_hash
OR NEW.key_prefix IS DISTINCT FROM OLD.key_prefix
OR NEW.mode IS DISTINCT FROM OLD.mode
OR NEW.refresh_token_hash IS DISTINCT FROM OLD.refresh_token_hash
OR NEW.previous_key_hash IS DISTINCT FROM OLD.previous_key_hash
OR NEW.previous_key_expires_at IS DISTINCT FROM OLD.previous_key_expires_at
OR NEW.previous_refresh_token_hash IS DISTINCT FROM OLD.previous_refresh_token_hash
OR NEW.previous_refresh_expires_at IS DISTINCT FROM OLD.previous_refresh_expires_at THEN
RAISE EXCEPTION 'api_keys: identity and credential columns are immutable from a user session'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS api_keys_guard_jwt_writes ON public.api_keys;
CREATE TRIGGER api_keys_guard_jwt_writes
BEFORE INSERT OR UPDATE ON public.api_keys
FOR EACH ROW EXECUTE FUNCTION public.api_keys_guard_jwt_writes();
-- 3: hash-as-bearer RPCs become service_role only --------------------------
REVOKE EXECUTE ON FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
TO service_role;
ALTER FUNCTION public.rotate_mcp_refresh_token(text, text, text, text, integer)
SET search_path = public;
-- 4: validate_and_increment_api_key with a membership check -----------------
-- Body identical to 20260831111519 apart from the membership block and the
-- fixed search_path.
CREATE OR REPLACE 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
SET search_path = public
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;
-- A key outlives neither the membership it was minted under nor the company
-- itself. Company-less keys (OAuth lazy bind) have nothing to check yet.
IF v_company_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.company_members cm
JOIN public.companies c ON c.id = cm.company_id AND c.archived_at IS NULL
WHERE cm.user_id = v_user_id
AND cm.company_id = v_company_id
) THEN
RETURN; -- treated as an unknown key: 401 upstream
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;
$$;
REVOKE EXECUTE ON FUNCTION public.validate_and_increment_api_key(text)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_and_increment_api_key(text)
TO service_role;
-- 5: provider token tables become service_role only --------------------------
DROP POLICY IF EXISTS provider_consent_tokens_select ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_insert ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_update ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_consent_tokens_delete ON public.provider_consent_tokens;
DROP POLICY IF EXISTS provider_otc_select ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_insert ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_update ON public.provider_otc;
DROP POLICY IF EXISTS provider_otc_delete ON public.provider_otc;
ALTER TABLE public.provider_consent_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.provider_otc ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE public.provider_consent_tokens FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.provider_otc FROM PUBLIC, anon, authenticated;
GRANT ALL ON TABLE public.provider_consent_tokens TO service_role;
GRANT ALL ON TABLE public.provider_otc TO service_role;
@@ -0,0 +1,780 @@
-- Security audit 2026-09-01, high items (report "Accounted Security Audit").
-- pg-test: tests/pg/security-role-gates-and-posting-integrity.pg.test.ts
--
-- A. The read-only `viewer` role could write. The 20260702093000 design
-- blocks viewers only through RLS predicates that AND in
-- current_user_can_write(); everything that bypasses RLS skipped the gate:
-- 15 authenticated-callable SECURITY DEFINER writers (commit_journal_entry,
-- import_sie_journal_entries, bulk_book_transactions, match_batch_allocate,
-- link_*_to_voucher, create_document_version, next_voucher_number,
-- reserve/release_voucher_range, rotate_company_inbox,
-- generate_invoice_number, ensure_company_dimensions,
-- relink_documents_to_correction, seed_chart_of_accounts) and ~45 tables
-- whose write policies were membership-only. Rather than re-emitting 15
-- function bodies and 130 policies, this adds ONE table-level guard,
-- enforce_company_writer_role(), on every company-scoped table a viewer
-- must never write. It keys on the JWT role claim (so it fires inside
-- SECURITY DEFINER bodies too, where current_user is the owner) and on
-- auth.uid(), and is a no-op for service_role, pg_cron, migrations and
-- trigger cascades (pg_trigger_depth() > 1). Deliberately NOT attached:
-- agent_conversations/agent_messages (viewer chat is a feature),
-- booking_template_usage and categorize_calibration_samples (telemetry a
-- read-only browsing session emits).
--
-- B. Admin -> owner escalation. company_members_update only guarded role
-- changes, so an admin could re-point the owner row's user_id; the
-- invitations CHECK allowed role 'owner' and the accept path copies the
-- role verbatim under the service client; team_members had no transition
-- guard at all. Triggers below close all three. companies gains an
-- owner-only guard on team_id/archived_at/created_by and the INSERT policy
-- now requires membership of the team a company is attached to.
--
-- C. Posting integrity. Direct PostgREST statements (current_user =
-- authenticated) could INSERT lines under a posted verifikat, INSERT a
-- header with status 'posted', or flip a self-numbered draft to posted,
-- bypassing commit_journal_entry and voucher sequencing (BFL 5 kap. 5 §,
-- BFNAR 2013:2). These checks key on current_user IN ('anon',
-- 'authenticated'): inside SECURITY DEFINER RPCs current_user is the
-- definer, so the engine's sanctioned paths (commit_journal_entry,
-- import_sie_journal_entries, correct_entry_lines_inline, storno) are
-- untouched, while the engine's own direct writes stay legal: it inserts
-- drafts (voucher 0, or a sequence-issued number for reversals) and flips
-- draft -> posted only with a sequence-issued number.
--
-- D. create_document_version: role gate + storage_path must live under the
-- document's company (or the caller's user folder). validate_version_chain
-- was anon-callable with no tenant check (a document-UUID oracle):
-- membership required, anon revoked. match_documents /
-- match_booking_templates lose anon EXECUTE. update_overdue_supplier_invoices
-- and redact_expired_invoice_delivery_pii are cron maintenance with no
-- application caller: service_role only. seed_asset_categories existed only
-- in production (no migration, no code reference): dropped.
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.jwt_caller_is_end_user()
RETURNS boolean
LANGUAGE sql
STABLE
SET search_path = public
AS $$
SELECT coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
''
) IN ('anon', 'authenticated');
$$;
-- Membership with a writing role, for an explicit company (the
-- current_user_can_write() twin that does not depend on the active company).
CREATE OR REPLACE FUNCTION public.caller_can_write_company(p_company_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
-- No archived_at filter here: archived companies are already frozen by the
-- migration-reset triggers, which must keep producing their own message.
SELECT p_company_id IS NOT NULL AND EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.company_id = p_company_id
AND cm.role <> 'viewer'
);
$$;
CREATE OR REPLACE FUNCTION public.caller_is_company_owner(p_company_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p_company_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.user_id = auth.uid() AND cm.company_id = p_company_id AND cm.role = 'owner'
);
$$;
CREATE OR REPLACE FUNCTION public.caller_is_team_owner(p_team_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p_team_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.user_id = auth.uid() AND tm.team_id = p_team_id AND tm.role = 'owner'
);
$$;
-- ---------------------------------------------------------------------------
-- A. Viewer write guard
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_company_writer_role()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_company uuid;
BEGIN
IF NOT public.jwt_caller_is_end_user() OR pg_trigger_depth() > 1 THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
v_company := OLD.company_id;
ELSE
v_company := NEW.company_id;
END IF;
IF v_company IS NOT NULL AND NOT public.caller_can_write_company(v_company) THEN
RAISE EXCEPTION 'row-level security: no write access to company % for % on %', v_company, TG_OP, TG_TABLE_NAME
USING ERRCODE = '42501';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
-- Child tables: resolve company_id through the parent named in TG_ARGV.
CREATE OR REPLACE FUNCTION public.enforce_company_writer_role_via_parent()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row jsonb;
v_fk uuid;
v_company uuid;
BEGIN
IF NOT public.jwt_caller_is_end_user() OR pg_trigger_depth() > 1 THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
v_row := to_jsonb(OLD);
ELSE
v_row := to_jsonb(NEW);
END IF;
v_fk := (v_row ->> TG_ARGV[1])::uuid;
IF v_fk IS NOT NULL THEN
EXECUTE format('SELECT company_id FROM public.%I WHERE id = $1', TG_ARGV[0])
INTO v_company USING v_fk;
IF v_company IS NOT NULL AND NOT public.caller_can_write_company(v_company) THEN
RAISE EXCEPTION 'row-level security: no write access to company % for % on %', v_company, TG_OP, TG_TABLE_NAME
USING ERRCODE = '42501';
END IF;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
-- core tables reachable through membership-only SECURITY DEFINER writers
'journal_entries', 'transactions', 'invoices', 'invoice_payments',
'supplier_invoices', 'supplier_invoice_payments', 'document_attachments',
'voucher_sequences', 'company_inboxes', 'chart_of_accounts',
-- tables whose own write policies were membership-only
'account_dimension_rules', 'accrual_schedule_installments', 'accrual_schedules',
'agent_memory', 'agent_profiles', 'agi_declarations',
'arsredovisning_narratives', 'arsredovisning_signature_requests', 'articles',
'assets', 'cash_accounts', 'depreciation_schedules', 'dimension_values',
'dimensions', 'employee_benefits', 'employee_opening_balances',
'employee_vacation_balances', 'employees', 'journal_entry_no_doc_required',
'mileage_trips', 'recurring_invoice_schedules', 'rot_rut_payout_requests',
'salary_absence_days', 'salary_line_items', 'salary_payslip_deliveries',
'salary_payslip_links', 'salary_run_employees', 'salary_runs',
'salary_worked_days', 'shift_premium_rules', 'shopify_connections',
'skattekonto_file_imports', 'skattekonto_rules', 'skattekonto_transactions',
'stripe_connections', 'supplier_payment_batch_items', 'supplier_payment_batches',
'transaction_voucher_links', 'vacation_year_closures', 'webshop_orders',
'webshop_store_settings', 'woocommerce_connections'
] LOOP
IF to_regclass('public.' || t) IS NULL THEN
RAISE NOTICE 'enforce_company_writer_role: table % missing, skipped', t;
CONTINUE;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS aa_enforce_company_writer_role ON public.%I', t);
EXECUTE format(
'CREATE TRIGGER aa_enforce_company_writer_role BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role()', t);
END LOOP;
END $$;
DO $$
DECLARE
spec text[];
BEGIN
FOREACH spec SLICE 1 IN ARRAY ARRAY[
ARRAY['journal_entry_lines', 'journal_entries', 'journal_entry_id'],
ARRAY['invoice_items', 'invoices', 'invoice_id'],
ARRAY['supplier_invoice_items', 'supplier_invoices', 'supplier_invoice_id'],
ARRAY['recurring_invoice_schedule_items', 'recurring_invoice_schedules', 'schedule_id'],
ARRAY['rot_rut_payout_request_items', 'rot_rut_payout_requests', 'request_id']
] LOOP
IF to_regclass('public.' || spec[1]) IS NULL THEN
RAISE NOTICE 'enforce_company_writer_role_via_parent: table % missing, skipped', spec[1];
CONTINUE;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS aa_enforce_company_writer_role ON public.%I', spec[1]);
EXECUTE format(
'CREATE TRIGGER aa_enforce_company_writer_role BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role_via_parent(%L, %L)',
spec[1], spec[2], spec[3]);
END LOOP;
END $$;
-- ---------------------------------------------------------------------------
-- B. Membership and ownership guards
-- ---------------------------------------------------------------------------
-- company_members: identity columns are immutable from user sessions; role
-- changes stay owner-only (unchanged semantics from 20260826130100).
CREATE OR REPLACE FUNCTION public.enforce_company_member_role_transitions()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
caller_role text;
BEGIN
-- Service role, SECURITY DEFINER cascades, and direct SQL have no auth context.
IF auth.uid() IS NULL THEN
RETURN NEW;
END IF;
-- Trigger-cascaded writes come from our own sync triggers
-- (team_member_sync_role_update), not from a direct user statement.
IF pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
-- Who a membership row belongs to is never editable: re-pointing the owner
-- row's user_id was an admin -> owner takeover.
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id THEN
RAISE EXCEPTION 'company_members: user_id and company_id are immutable'
USING ERRCODE = '42501';
END IF;
-- Nothing to enforce if the role field isn't changing.
IF NEW.role IS NOT DISTINCT FROM OLD.role THEN
RETURN NEW;
END IF;
caller_role := public.user_role_in_company(OLD.company_id);
IF caller_role IS DISTINCT FROM 'owner' THEN
RAISE EXCEPTION
'Only owners can change member roles (your role: %)',
COALESCE(caller_role, 'none');
END IF;
RETURN NEW;
END;
$$;
-- company_invitations: an invitation never mints an owner. The API schema
-- already refused it; the RLS INSERT policy (admin) and the CHECK constraint
-- did not, and the accept path copies the role under the service client.
CREATE OR REPLACE FUNCTION public.enforce_company_invitation_role()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF NEW.role = 'owner' THEN
RAISE EXCEPTION 'company_invitations: role owner cannot be granted by invitation'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_company_invitation_role ON public.company_invitations;
CREATE TRIGGER enforce_company_invitation_role
BEFORE INSERT OR UPDATE OF role ON public.company_invitations
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_invitation_role();
-- team_members: mirror of the company_members guard. The first owner of a
-- team may be self-inserted (create_team_with_owner / ensure_user_team run as
-- definer with the caller's JWT); every later owner grant needs a team owner.
CREATE OR REPLACE FUNCTION public.enforce_team_member_role_transitions()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL OR pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.role = 'owner'
AND NOT public.caller_is_team_owner(NEW.team_id)
AND NOT (
NEW.user_id = auth.uid()
AND NOT EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.team_id = NEW.team_id AND tm.role = 'owner'
)
) THEN
RAISE EXCEPTION 'team_members: only a team owner can add another owner'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.team_id IS DISTINCT FROM OLD.team_id THEN
RAISE EXCEPTION 'team_members: user_id and team_id are immutable'
USING ERRCODE = '42501';
END IF;
-- Team admins keep the pre-existing ability to move members between the
-- non-owner roles; anything touching 'owner' needs a team owner.
IF NEW.role IS DISTINCT FROM OLD.role
AND NOT public.caller_is_team_owner(OLD.team_id)
AND NOT (
public.user_is_team_admin(OLD.team_id)
AND OLD.role <> 'owner' AND NEW.role <> 'owner'
) THEN
RAISE EXCEPTION 'team_members: only a team owner can grant or remove the owner role'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_team_member_role_transitions ON public.team_members;
CREATE TRIGGER enforce_team_member_role_transitions
BEFORE INSERT OR UPDATE ON public.team_members
FOR EACH ROW EXECUTE FUNCTION public.enforce_team_member_role_transitions();
-- companies: team attachment, archiving and provenance are owner-only from a
-- user session, and a company may only be attached to a team the caller
-- belongs to. Service-role paths (delete route, migration reset internals)
-- are unaffected.
CREATE OR REPLACE FUNCTION public.enforce_company_owner_only_columns()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL OR pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF NEW.created_by IS DISTINCT FROM OLD.created_by THEN
RAISE EXCEPTION 'companies: created_by is immutable' USING ERRCODE = '42501';
END IF;
IF NEW.team_id IS DISTINCT FROM OLD.team_id THEN
IF NOT public.caller_is_company_owner(OLD.id) THEN
RAISE EXCEPTION 'companies: only the owner can change the team attachment'
USING ERRCODE = '42501';
END IF;
IF NEW.team_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.team_members tm WHERE tm.team_id = NEW.team_id AND tm.user_id = auth.uid()
) THEN
RAISE EXCEPTION 'companies: cannot attach a company to a team you are not a member of'
USING ERRCODE = '42501';
END IF;
END IF;
IF (NEW.archived_at IS DISTINCT FROM OLD.archived_at
OR NEW.archived_by IS DISTINCT FROM OLD.archived_by)
AND NOT public.caller_is_company_owner(OLD.id) THEN
RAISE EXCEPTION 'companies: only the owner can archive or restore a company'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_company_owner_only_columns ON public.companies;
CREATE TRIGGER enforce_company_owner_only_columns
BEFORE UPDATE ON public.companies
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_owner_only_columns();
DROP POLICY IF EXISTS "companies_insert" ON public.companies;
CREATE POLICY "companies_insert" ON public.companies
FOR INSERT
WITH CHECK (
created_by = auth.uid()
AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids()))
);
-- ---------------------------------------------------------------------------
-- C. Posting integrity for direct statements
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_insert_shape()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
-- Direct PostgREST statement (current_user is the end-user role). Inside a
-- SECURITY DEFINER RPC current_user is the definer, so the engine's
-- sanctioned writers are not affected.
IF current_user NOT IN ('anon', 'authenticated') THEN
RETURN NEW;
END IF;
IF NEW.status IS DISTINCT FROM 'draft'
OR NEW.committed_at IS NOT NULL
OR NEW.commit_method IS NOT NULL THEN
RAISE EXCEPTION 'journal_entries: direct inserts must be drafts; post through commit_journal_entry'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS aa_enforce_journal_entry_insert_shape ON public.journal_entries;
CREATE TRIGGER aa_enforce_journal_entry_insert_shape
BEFORE INSERT ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_insert_shape();
-- Header immutability: identical to the live definition, plus one rule in the
-- draft branch: a direct statement may flip draft -> posted only with a
-- voucher number the sequence has actually issued (0 < n <= last_number).
-- The engine's reversal path (next_voucher_number then UPDATE) satisfies it;
-- a self-chosen number outside the sequence does not.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_last_number integer;
BEGIN
IF TG_OP = 'DELETE' THEN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
IF NEW.status = 'posted' AND current_user IN ('anon', 'authenticated') THEN
SELECT vs.last_number INTO v_last_number
FROM public.voucher_sequences vs
WHERE vs.company_id = NEW.company_id
AND vs.fiscal_period_id = NEW.fiscal_period_id
AND vs.voucher_series = NEW.voucher_series;
IF NEW.voucher_number IS NULL OR NEW.voucher_number <= 0
OR v_last_number IS NULL OR NEW.voucher_number > v_last_number THEN
RAISE EXCEPTION 'journal_entries: voucher number % was not issued by the sequence; post through commit_journal_entry',
NEW.voucher_number USING ERRCODE = '42501';
END IF;
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number
OR NEW.commit_method IS DISTINCT FROM OLD.commit_method
OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version
OR NEW.source_voucher_series IS DISTINCT FROM OLD.source_voucher_series
OR NEW.source_voucher_number IS DISTINCT FROM OLD.source_voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
-- Narrow un-reversal path: when delete_last_voucher removes a storno entry,
-- it flips the original from 'reversed' back to 'posted'. No other fields
-- may change, and the bypass flag must be set.
IF OLD.status = 'reversed' AND NEW.status = 'posted'
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields during un-reversal (id: %)', OLD.id;
END IF;
RETURN NEW;
END IF;
-- Notes-only annotation on a committed entry (posted/reversed/cancelled).
IF OLD.status = NEW.status
AND OLD.status IN ('posted', 'reversed', 'cancelled')
AND (to_jsonb(NEW) - 'notes' - 'updated_at')
= (to_jsonb(OLD) - 'notes' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Source-type re-tag of a mis-typed opening balance (mark_entry_as_opening_balance).
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_source_type_retag', true) = 'true'
AND OLD.source_type IN ('manual', 'import')
AND NEW.source_type = 'opening_balance'
AND (to_jsonb(NEW) - 'source_type' - 'updated_at')
= (to_jsonb(OLD) - 'source_type' - 'updated_at') THEN
RETURN NEW;
END IF;
-- Metadata rättelse of a posted verifikation (correct_entry_metadata).
IF OLD.status = NEW.status
AND OLD.status = 'posted'
AND current_setting('gnubok.allow_metadata_rattelse', true) = 'true'
AND (to_jsonb(NEW) - 'description' - 'entry_date' - 'updated_at')
= (to_jsonb(OLD) - 'description' - 'entry_date' - 'updated_at') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END;
$$;
-- Line immutability: identical to the live definition, plus INSERT coverage.
-- A direct statement may only add lines to a draft header; sanctioned RPCs
-- (correct_entry_lines_inline under its GUC, import, storno) run as definer.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE v_status text;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(NEW.journal_entry_id, OLD.journal_entry_id);
IF TG_OP = 'INSERT' THEN
IF v_status IS DISTINCT FROM 'draft'
AND current_user IN ('anon', 'authenticated')
AND current_setting('gnubok.allow_line_rattelse', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'journal_entry_lines: cannot add lines to a % journal entry', v_status
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END IF;
-- Dimension retag carve-out (dimensions plan PR6, founder-approved):
-- while the transaction-local GUC set by retag_line_dimensions is active,
-- permit UPDATE of a POSTED line iff ONLY the dimension columns change.
IF TG_OP = 'UPDATE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_dimension_retag', true) = 'true'
AND (to_jsonb(NEW) - 'dimensions' - 'cost_center' - 'project')
= (to_jsonb(OLD) - 'dimensions' - 'cost_center' - 'project') THEN
RETURN NEW;
END IF;
-- Inline rättelse carve-out (BFL 5 kap 5 §, founder-approved 2026-07-23):
-- while the transaction-local GUC set by correct_entry_lines_inline() is
-- active, permit DELETE of a POSTED line (a struck line).
IF TG_OP = 'DELETE'
AND v_status = 'posted'
AND current_setting('gnubok.allow_line_rattelse', true) = 'true' THEN
RETURN OLD;
END IF;
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $$;
DROP TRIGGER IF EXISTS enforce_journal_entry_line_immutability ON public.journal_entry_lines;
CREATE TRIGGER enforce_journal_entry_line_immutability
BEFORE INSERT OR UPDATE OR DELETE ON public.journal_entry_lines
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_line_immutability();
-- ---------------------------------------------------------------------------
-- D. Document RPCs and leftover grants
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid, p_original_doc_id uuid, p_storage_path text, p_file_name text,
p_file_size_bytes bigint, p_mime_type text, p_sha256_hash text)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller uuid := auth.uid();
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
v_caller_role text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Authentication required to create document version';
END IF;
IF p_user_id IS DISTINCT FROM v_caller THEN
RAISE EXCEPTION 'p_user_id does not match authenticated user';
END IF;
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
SELECT cm.role INTO v_caller_role
FROM public.company_members cm
WHERE cm.company_id = v_current.company_id
AND cm.user_id = v_caller;
IF v_caller_role IS NULL THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (v_caller, v_current.company_id, 'SECURITY_EVENT', 'document_attachments', p_original_doc_id,
'Blocked cross-company create_document_version attempt');
RAISE EXCEPTION 'User is not a member of the document''s company';
END IF;
-- Read-only members supersede nothing: same rule as every document write.
IF v_caller_role = 'viewer' THEN
RAISE EXCEPTION 'read-only role cannot create document versions' USING ERRCODE = '42501';
END IF;
-- The new object must live in this company's folder (or the caller's own
-- legacy user folder): a foreign key would re-anchor another tenant's file.
IF p_storage_path IS NULL
OR NOT (
p_storage_path LIKE 'documents/' || v_current.company_id::text || '/%'
OR p_storage_path LIKE 'documents/' || v_caller::text || '/%'
) THEN
RAISE EXCEPTION 'storage_path must be under the document''s company folder' USING ERRCODE = '42501';
END IF;
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
PERFORM set_config('gnubok.allow_supersede', 'true', true);
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash
)
RETURNING id INTO v_new_id;
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
VALUES (
v_caller, v_current.company_id, 'UPDATE', 'document_attachments', p_original_doc_id, v_caller,
'Document superseded: v' || v_current.version || ' (' || v_current.sha256_hash || ') -> v' || v_next_version || ' (' || p_sha256_hash || '); new id=' || v_new_id
);
RETURN v_new_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_root_id uuid;
v_company uuid;
BEGIN
SELECT COALESCE(da.original_id, da.id), da.company_id INTO v_root_id, v_company
FROM public.document_attachments da
WHERE da.id = p_document_id;
-- One message for "missing" and "not yours": no existence oracle.
IF v_root_id IS NULL
OR (public.jwt_caller_is_end_user() AND NOT public.caller_is_company_member(v_company)) THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$$;
REVOKE EXECUTE ON FUNCTION public.validate_version_chain(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.validate_version_chain(uuid) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.match_documents(vector, integer, double precision) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_documents(vector, integer, double precision) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.match_booking_templates(vector, integer, double precision) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_booking_templates(vector, integer, double precision) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.update_overdue_supplier_invoices() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.update_overdue_supplier_invoices() TO service_role;
REVOKE EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
-- Production-only leftover with no migration and no caller.
DROP FUNCTION IF EXISTS public.seed_asset_categories(uuid);
@@ -0,0 +1,27 @@
-- Security audit 2026-09-01: bind the arcim-migration OAuth state to the user
-- who started the flow.
--
-- provider_otc rows are the `state` handed to Fortnox/Visma when a user starts
-- a provider connect (extensions/general/arcim-migration). The unauthenticated
-- GET /callback resolved the consent from that row and exchanged the code for
-- tokens without asking WHO completed the flow: a victim lured into approving
-- a consent someone else started had their provider account bound to the
-- initiator's consent (and the initiator's next migration imported the
-- victim's ledger). The callback now compares the completing browser's cookie
-- session to the initiator recorded here (lib/auth/oauth-flow-binding.ts).
--
-- Nullable on purpose: rows minted before this migration carry no initiator.
-- They expire within 10 minutes and the callback refuses them, so at most one
-- in-flight connect has to be restarted at deploy time.
--
-- No policy change: 20260902090000 made provider_otc service_role only (all
-- member policies dropped, privileges revoked from anon/authenticated), and
-- every reader and writer goes through createServiceClient().
ALTER TABLE public.provider_otc
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
COMMENT ON COLUMN public.provider_otc.user_id IS
'The user who started the OAuth flow this state was minted for. GET /callback refuses a completion by any other session.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,33 @@
-- BankID signup: the e-mail address must be proven before the identity counts.
--
-- Security audit 2026-09: in signup mode the BankID /complete route created
-- an email-confirmed, MFA-exempt (app_metadata.bankid_linked) account for
-- whatever address the caller typed, without ever mailing that address. The
-- real owner of the address could later adopt the account (forgot-password,
-- Google login) while the BankID holder kept a permanent login into it
-- (account pre-hijacking).
--
-- email_verified_at records when the address on the linked auth user was
-- confirmed through the confirmation mail the BankID signup now sends. NULL
-- means the identity is PENDING: BankID login is refused and the account is
-- not MFA-exempt until the link in that mail is clicked (app/(auth)/auth/
-- callback promotes it). A pending row attached to an account that has since
-- been adopted through another credential is deleted, never promoted.
--
-- No column default on purpose (fail closed): every insert path must say
-- whether the address is proven. The authenticated /bankid/link route writes
-- now(); the BankID signup writes NULL. Existing rows predate this change and
-- were adopted through the old flow, so they are grandfathered as verified at
-- their creation time.
ALTER TABLE public.bankid_identities
ADD COLUMN IF NOT EXISTS email_verified_at timestamptz NULL;
UPDATE public.bankid_identities
SET email_verified_at = created_at
WHERE email_verified_at IS NULL;
COMMENT ON COLUMN public.bankid_identities.email_verified_at IS
'When the linked auth user''s e-mail address was proven for this BankID identity. NULL = pending: BankID login refused, no MFA exemption, until the signup confirmation mail is clicked. Rows created before 2026-09-02 are backfilled with created_at.';
NOTIFY pgrst, 'reload schema';