-- 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 $$;