-- ============================================================================= -- 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. -- -------------------------------------------------------