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>
50 lines
2.8 KiB
PL/PgSQL
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 $$;
|