add features
This commit is contained in:
146
backend/src/database/migrations/002_create_audit_log.sql
Normal file
146
backend/src/database/migrations/002_create_audit_log.sql
Normal file
@@ -0,0 +1,146 @@
|
||||
-- =============================================================================
|
||||
-- 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.
|
||||
-- -------------------------------------------------------
|
||||
Reference in New Issue
Block a user