Files
accounted/supabase/migrations/20260517135000_skatteverket_audit_franvaro_lock.sql
Mattsson a652dcae1a Skv/e2e overview (#515)
* feat(agi): refactor AGI XML generation and data handling

- Remove deprecated AGI field codes from field-codes.ts.
- Update generate-declaration.ts to include new employee fields and handle absence data with stable specification numbers.
- Enhance XML generation in xml-generator.ts to support new flags for housing benefits and adjusted benefits.
- Introduce new database migrations to support:
  - `removed_from_agi` flag for tombstoning individuppgifter.
  - `benefits_adjusted` flag for tracking adjustments to benefits.
  - `franvaro_specifikationsnummer` for stable absence event identification.
  - `housing_benefit_type` to differentiate between housing benefit types.

* feat: Implement strict validation for AGI employee data and introduce pre-flight validation schemas

- Added Zod schemas for validating employee data in AGI declarations to ensure all required fields are present and correctly typed, preventing silent errors during processing.
- Introduced AGI pre-flight validation schemas for Skatteverket endpoints to validate individual and head unit submissions before sending to the API.
- Created a new audit log table for tracking all outbound calls to Skatteverket, ensuring compliance and traceability for AGI and moms submissions.
- Implemented advisory locks in the database to manage concurrent updates to absence specification numbers, enhancing data integrity.
- Added compliance documentation for GDPR processing activities related to AGI and moms submissions, detailing data handling and retention policies.

* feat: Extend DELETE RLS policy to protect 'declined' signatures in årsredovisning

* fix: Update date handling in salary absence migrations to use immutable year-month key

* fix: Update SELECT policy in skatteverket_api_audit_log to use IN clause for company_id

* feat: Add skatteverket_api_audit_log and salary_absence_franvaro_audit tables with RLS policies and immutable triggers
2026-05-17 23:52:49 +02:00

177 lines
7.8 KiB
PL/PgSQL

-- skatteverket_api_audit_log: long-lived audit trail for every outbound call
-- to a Skatteverket regulator endpoint. Separate from audit_log (which is
-- DB-trigger-only and lacks company_id/endpoint columns). Type II reviewers
-- need to reconstruct who submitted what AGI/moms payload to SKV and when.
--
-- Append-only. RLS by company_id. No retention TTL — these records are part
-- of räkenskapsinformation under BFL 7 kap (7-year minimum).
CREATE TABLE public.skatteverket_api_audit_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
user_id uuid NOT NULL, -- no cascade: survives user deletion
endpoint text NOT NULL, -- e.g. 'agi.kontrollera.hu', 'agi.submit', 'moms.declaration.lock'
ag_registered_id text, -- 12-digit IDENTITET (orgnr or pnr) when known
redovisningsperiod text, -- YYYYMM when applicable
outcome text NOT NULL CHECK (outcome IN ('ok', 'validation_error', 'skv_error', 'auth_error', 'internal_error')),
response_status integer, -- HTTP status from SKV (or local error code)
skv_status text, -- 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE' for kontrollsvar
request_size_bytes integer,
correlation_id text, -- for cross-system tracing
error_message text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.skatteverket_api_audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY skatteverket_api_audit_log_select ON public.skatteverket_api_audit_log
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
-- No INSERT/UPDATE/DELETE policies for normal roles — service role writes
-- only. Immutability triggers below block all UPDATE/DELETE regardless of role.
CREATE INDEX idx_skv_audit_company_created ON public.skatteverket_api_audit_log (company_id, created_at DESC);
CREATE INDEX idx_skv_audit_endpoint ON public.skatteverket_api_audit_log (endpoint);
CREATE INDEX idx_skv_audit_user ON public.skatteverket_api_audit_log (user_id);
CREATE OR REPLACE FUNCTION public.skatteverket_audit_immutable()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Skatteverket audit log entries cannot be modified or deleted';
END;
$$;
CREATE TRIGGER skv_audit_no_update
BEFORE UPDATE ON public.skatteverket_api_audit_log
FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable();
CREATE TRIGGER skv_audit_no_delete
BEFORE DELETE ON public.skatteverket_api_audit_log
FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable();
-- salary_absence_franvaro_audit: SOC 2 CC7.2 — record every assignment of
-- franvaro_specifikationsnummer by the DB trigger so a Type II reviewer can
-- reconstruct the sequence history per (employee, year-month). The trigger
-- runs SECURITY DEFINER (implicit in plpgsql functions that own the table);
-- without this, an auditor has no record of when each number was minted.
CREATE TABLE public.salary_absence_franvaro_audit (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
absence_day_id uuid NOT NULL,
employee_id uuid NOT NULL,
absence_date date NOT NULL,
year_month text NOT NULL, -- YYYY-MM
old_specifikationsnummer integer,
new_specifikationsnummer integer NOT NULL,
trigger_op text NOT NULL CHECK (trigger_op IN ('insert', 'update')),
assigned_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.salary_absence_franvaro_audit ENABLE ROW LEVEL SECURITY;
CREATE INDEX idx_franvaro_audit_employee_month
ON public.salary_absence_franvaro_audit (employee_id, year_month, assigned_at);
CREATE OR REPLACE FUNCTION public.franvaro_audit_immutable()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Frånvaro audit entries cannot be modified or deleted';
END;
$$;
CREATE TRIGGER franvaro_audit_no_update
BEFORE UPDATE ON public.salary_absence_franvaro_audit
FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable();
CREATE TRIGGER franvaro_audit_no_delete
BEFORE DELETE ON public.salary_absence_franvaro_audit
FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable();
-- Replace the trigger functions to (1) take an advisory lock per
-- (employee_id, year-month) so concurrent inserts serialise rather than
-- racing on MAX(...)+1 and surfacing as a unique-violation, and (2) write an
-- audit row capturing the assignment.
--
-- pg_advisory_xact_lock is released at transaction commit/rollback and is
-- keyed by a 64-bit int built from the employee UUID's low 32 bits XOR'd
-- with the year-month integer. Collisions across employees in different
-- months are harmless (just serialise more than needed); collisions across
-- different employees in the same month are statistically negligible and
-- still correct.
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer()
RETURNS TRIGGER AS $$
DECLARE
v_lock_key bigint;
v_year_month text;
v_new_num integer;
BEGIN
IF NEW.franvaro_specifikationsnummer IS NULL
AND NEW.absence_type IN ('vab', 'parental') THEN
v_year_month := to_char(NEW.absence_date, 'YYYY-MM');
v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint
# (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int);
PERFORM pg_advisory_xact_lock(v_lock_key);
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
INTO v_new_num
FROM salary_absence_days
WHERE employee_id = NEW.employee_id
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
AND franvaro_specifikationsnummer IS NOT NULL;
NEW.franvaro_specifikationsnummer := v_new_num;
INSERT INTO public.salary_absence_franvaro_audit (
absence_day_id, employee_id, absence_date, year_month,
old_specifikationsnummer, new_specifikationsnummer, trigger_op
) VALUES (
NEW.id, NEW.employee_id, NEW.absence_date, v_year_month,
NULL, v_new_num, 'insert'
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer_on_update()
RETURNS TRIGGER AS $$
DECLARE
v_lock_key bigint;
v_year_month text;
v_new_num integer;
BEGIN
IF NEW.franvaro_specifikationsnummer IS NULL
AND NEW.absence_type IN ('vab', 'parental') THEN
v_year_month := to_char(NEW.absence_date, 'YYYY-MM');
v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint
# (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int);
PERFORM pg_advisory_xact_lock(v_lock_key);
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
INTO v_new_num
FROM salary_absence_days
WHERE employee_id = NEW.employee_id
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
AND franvaro_specifikationsnummer IS NOT NULL;
NEW.franvaro_specifikationsnummer := v_new_num;
INSERT INTO public.salary_absence_franvaro_audit (
absence_day_id, employee_id, absence_date, year_month,
old_specifikationsnummer, new_specifikationsnummer, trigger_op
) VALUES (
NEW.id, NEW.employee_id, NEW.absence_date, v_year_month,
OLD.franvaro_specifikationsnummer, v_new_num, 'update'
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
NOTIFY pgrst, 'reload schema';