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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
6e8d76a9cb
commit
18cbc4c30a
+239
@@ -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;
|
||||
+780
@@ -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';
|
||||
Reference in New Issue
Block a user