Files
dashboard/backend/src/database/migrations/002_create_audit_log.sql
Matthias Hochmeister 620bacc6b5 add features
2026-02-27 19:50:14 +01:00

147 lines
5.6 KiB
SQL

-- =============================================================================
-- Migration 002: Audit Log Table
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
--
-- Design decisions:
-- - UUID primary key (consistent with users table)
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
-- - old_value / new_value are JSONB: flexible, queryable, indexable
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
-- SQL level, which is simpler than triggers and cannot be bypassed by the
-- application role
-- - Partial index on ip_address covers only recent rows (90-day window) to
-- support efficient anonymisation queries without a full-table scan
-- =============================================================================
-- -------------------------------------------------------
-- 1. Enums — define before the table
-- -------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'EXPORT',
'PERMISSION_DENIED',
'PASSWORD_CHANGE',
'ROLE_CHANGE'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
CREATE TYPE audit_resource_type AS ENUM (
'MEMBER',
'INCIDENT',
'VEHICLE',
'EQUIPMENT',
'QUALIFICATION',
'USER',
'SYSTEM'
);
END IF;
END
$$;
-- -------------------------------------------------------
-- 2. Core audit_log table
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
action audit_action NOT NULL,
resource_type audit_resource_type NOT NULL,
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
old_value JSONB, -- state before the operation
new_value JSONB, -- state after the operation
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
user_agent TEXT,
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent modification of audit records at the database level.
-- Using RULE rather than a trigger because rules are evaluated before
-- the operation and cannot be disabled without superuser access.
CREATE OR REPLACE RULE audit_log_no_update AS
ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE OR REPLACE RULE audit_log_no_delete AS
ON DELETE TO audit_log DO INSTEAD NOTHING;
-- -------------------------------------------------------
-- 3. Indexes
-- -------------------------------------------------------
-- Lookup by actor (admin "what did this user do?")
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
ON audit_log (user_id)
WHERE user_id IS NOT NULL;
-- Lookup by subject resource (admin "what happened to member X?")
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
ON audit_log (resource_type, resource_id);
-- Time-range queries and retention scans (most common filter)
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log (created_at DESC);
-- Action-type filter
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log (action);
-- Partial index: only rows where IP address is not yet anonymised.
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
-- AND ip_address != '[anonymized]'
-- This index makes that query O(matched rows) instead of O(table size).
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
ON audit_log (created_at)
WHERE ip_address IS NOT NULL
AND ip_address != '[anonymized]';
-- -------------------------------------------------------
-- 4. OPTIONAL — Range partitioning by month (recommended
-- for high-volume deployments; skip in small setups)
--
-- To use partitioning, replace the CREATE TABLE above with
-- the following DDL *before* the first row is inserted.
-- Partitioning cannot be added to an existing unpartitioned
-- table without a full rewrite (pg_partman can automate this).
--
-- CREATE TABLE audit_log (
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- user_email VARCHAR(255),
-- action audit_action NOT NULL,
-- resource_type audit_resource_type NOT NULL,
-- resource_id VARCHAR(255),
-- old_value JSONB,
-- new_value JSONB,
-- ip_address TEXT,
-- user_agent TEXT,
-- metadata JSONB DEFAULT '{}',
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- ) PARTITION BY RANGE (created_at);
--
-- -- Create monthly partitions with pg_partman or manually:
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- -- ... and so on. pg_partman automates partition creation.
--
-- With partitioning, old partitions can be DROPped for
-- efficient bulk retention deletion without a full-table scan.
-- -------------------------------------------------------