147 lines
5.6 KiB
SQL
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.
|
|
-- -------------------------------------------------------
|