Files
vermietungs-tool/migrations/002_audit_log.sql
T
BlackMarkandClaude Opus 5.5 45424e6fd6 Vermietungs-Tool
A local web app for a small room-rental business in Austria: guest register,
room ledger, occupancy plan, invoices, the monthly tourist-tax report and the
quarterly VAT list. Application code only; it ships no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:52:27 +01:00

50 lines
2.8 KiB
PL/PgSQL

-- 002 audit log: every row a data table gains, loses or changes — before, after, and who wrote it.
-- Backups protect against losing the database, not against it being wrong; this shows which rows changed,
-- when and by whom, and `python -m app.audit revert` undoes just those. It is also the registration history
-- the Meldegesetz asks for: corrections through history, not by overwriting.
-- The trigger function runs as the owner (SECURITY DEFINER): the app's role may read the log but neither
-- write nor change it (db._grant), and only a table's owner can switch its triggers off.
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(), -- when the writing transaction began
tx bigint NOT NULL DEFAULT pg_current_xact_id()::text::bigint, -- revert one transaction whole
actor text COLLATE "C" NOT NULL, -- the connection's app.actor: web | cli | ...
db_user text COLLATE "C" NOT NULL DEFAULT session_user, -- the login role, unspoofable
table_name text COLLATE "C" NOT NULL,
op text COLLATE "C" NOT NULL, -- INSERT | UPDATE | DELETE | TRUNCATE
row_id bigint, -- null for company_key / dismissed_finding: their key is in the JSON
old jsonb,
new jsonb
);
CREATE INDEX audit_log_row ON audit_log (table_name, row_id);
CREATE INDEX audit_log_tx ON audit_log (tx);
CREATE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
DECLARE
o jsonb := CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END;
n jsonb := CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END;
BEGIN
IF TG_OP = 'UPDATE' AND o = n THEN
RETURN NULL; -- a save that changed nothing is no history
END IF;
EXECUTE format('INSERT INTO %I.audit_log (actor, table_name, op, row_id, old, new) '
'VALUES ($1, $2, $3, $4, $5, $6)', TG_TABLE_SCHEMA)
USING COALESCE(NULLIF(current_setting('app.actor', true), ''), 'unknown'), TG_TABLE_NAME, TG_OP,
(COALESCE(n, o) ->> 'id')::bigint, o, n;
RETURN NULL;
END $$;
REVOKE EXECUTE ON FUNCTION audit_row() FROM PUBLIC;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['unit', 'room', 'company', 'client', 'company_key', 'dismissed_finding', 'guest',
'invoice', 'stay', 'charge', 'payment'] LOOP
EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I '
'FOR EACH ROW EXECUTE FUNCTION audit_row()', t || '_audit', t);
EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %I '
'FOR EACH STATEMENT EXECUTE FUNCTION audit_row()', t || '_audit_truncate', t);
END LOOP;
END $$;