template-saas-database-v2/migrations/V20260120_003__migrate_audit_logs.sql
rckrdmrd 27de049441 [TEMPLATE-SAAS-DB] chore: Update audit schema and add migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:38:47 -06:00

388 lines
12 KiB
PL/PgSQL

-- ============================================
-- Migration: V20260120_003
-- Description: Migrate audit.audit_logs structure
-- Changes:
-- - Rename entity_type -> resource_type
-- - Rename entity_id -> resource_id
-- - Add user_email, actor_type columns
-- - Add resource_name, severity columns
-- - Add request_id, session_id columns
-- - Add endpoint, http_method, response_status, duration_ms columns
-- - Change changes JSONB -> old_values, new_values, changed_fields
-- ============================================
-- UP Migration
BEGIN;
-- ============================================
-- 1. Ensure severity ENUM exists
-- ============================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type
WHERE typname = 'severity'
AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'audit')
) THEN
CREATE TYPE audit.severity AS ENUM ('info', 'warning', 'error', 'critical');
RAISE NOTICE 'Created audit.severity enum';
END IF;
END $$;
-- ============================================
-- 2. Rename entity_type -> resource_type (if legacy)
-- ============================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'entity_type'
) THEN
-- Drop old index if exists
DROP INDEX IF EXISTS audit.idx_audit_logs_entity;
-- Rename column
ALTER TABLE audit.audit_logs RENAME COLUMN entity_type TO resource_type;
-- Update column type if needed
ALTER TABLE audit.audit_logs ALTER COLUMN resource_type TYPE VARCHAR(100);
RAISE NOTICE 'Renamed entity_type to resource_type';
END IF;
END $$;
-- ============================================
-- 3. Rename entity_id -> resource_id (if legacy)
-- ============================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'entity_id'
) THEN
ALTER TABLE audit.audit_logs RENAME COLUMN entity_id TO resource_id;
ALTER TABLE audit.audit_logs ALTER COLUMN resource_id TYPE VARCHAR(255);
RAISE NOTICE 'Renamed entity_id to resource_id';
END IF;
END $$;
-- ============================================
-- 4. Add actor columns
-- ============================================
-- user_email
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'user_email'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN user_email VARCHAR(255);
-- Backfill user_email from users table
UPDATE audit.audit_logs al
SET user_email = u.email
FROM users.users u
WHERE al.user_id = u.id
AND al.user_email IS NULL;
RAISE NOTICE 'Added user_email column and backfilled from users table';
END IF;
END $$;
-- actor_type
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'actor_type'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN actor_type VARCHAR(50) DEFAULT 'user';
RAISE NOTICE 'Added actor_type column';
END IF;
END $$;
-- ============================================
-- 5. Add resource_name column
-- ============================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'resource_name'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN resource_name VARCHAR(255);
RAISE NOTICE 'Added resource_name column';
END IF;
END $$;
-- ============================================
-- 6. Add severity column
-- ============================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'severity'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN severity audit.severity DEFAULT 'info';
-- Create index for severity filtering
CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(tenant_id, severity)
WHERE severity IN ('warning', 'error', 'critical');
RAISE NOTICE 'Added severity column';
END IF;
END $$;
-- ============================================
-- 7. Add context columns
-- ============================================
-- request_id
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'request_id'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN request_id VARCHAR(100);
RAISE NOTICE 'Added request_id column';
END IF;
END $$;
-- session_id
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'session_id'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN session_id UUID;
RAISE NOTICE 'Added session_id column';
END IF;
END $$;
-- ============================================
-- 8. Add HTTP context columns
-- ============================================
-- endpoint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'endpoint'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN endpoint VARCHAR(255);
RAISE NOTICE 'Added endpoint column';
END IF;
END $$;
-- http_method
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'http_method'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN http_method VARCHAR(10);
RAISE NOTICE 'Added http_method column';
END IF;
END $$;
-- response_status
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'response_status'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN response_status SMALLINT;
RAISE NOTICE 'Added response_status column';
END IF;
END $$;
-- duration_ms
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'duration_ms'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN duration_ms INTEGER;
RAISE NOTICE 'Added duration_ms column';
END IF;
END $$;
-- ============================================
-- 9. Migrate changes JSONB -> old_values, new_values, changed_fields
-- ============================================
-- old_values
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'old_values'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN old_values JSONB;
RAISE NOTICE 'Added old_values column';
END IF;
END $$;
-- new_values
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'new_values'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN new_values JSONB;
RAISE NOTICE 'Added new_values column';
END IF;
END $$;
-- changed_fields
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'changed_fields'
) THEN
ALTER TABLE audit.audit_logs ADD COLUMN changed_fields JSONB;
RAISE NOTICE 'Added changed_fields column';
END IF;
END $$;
-- Migrate data from 'changes' column if it exists
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'audit' AND table_name = 'audit_logs' AND column_name = 'changes'
) THEN
-- Extract old_values from changes
UPDATE audit.audit_logs
SET old_values = changes->'old'
WHERE changes IS NOT NULL
AND changes ? 'old'
AND old_values IS NULL;
-- Extract new_values from changes
UPDATE audit.audit_logs
SET new_values = changes->'new'
WHERE changes IS NOT NULL
AND changes ? 'new'
AND new_values IS NULL;
-- Extract changed_fields from changes
UPDATE audit.audit_logs
SET changed_fields = changes->'fields'
WHERE changes IS NOT NULL
AND changes ? 'fields'
AND changed_fields IS NULL;
-- Alternative: if changes is flat diff, create changed_fields from keys
UPDATE audit.audit_logs
SET changed_fields = (
SELECT jsonb_agg(key)
FROM jsonb_object_keys(changes) AS key
WHERE key NOT IN ('old', 'new', 'fields')
)
WHERE changes IS NOT NULL
AND NOT (changes ? 'old')
AND NOT (changes ? 'new')
AND changed_fields IS NULL;
-- Drop old column after migration
ALTER TABLE audit.audit_logs DROP COLUMN changes;
RAISE NOTICE 'Migrated changes column to old_values, new_values, changed_fields';
END IF;
END $$;
-- ============================================
-- 10. Update/create indexes for new structure
-- ============================================
-- Index for resource queries
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'audit' AND indexname = 'idx_audit_logs_resource'
) THEN
CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(tenant_id, resource_type, resource_id);
END IF;
END $$;
-- ============================================
-- 11. Update audit.log_event function
-- ============================================
CREATE OR REPLACE FUNCTION audit.log_event(
p_tenant_id UUID,
p_user_id UUID,
p_action audit.action_type,
p_resource_type VARCHAR,
p_resource_id VARCHAR DEFAULT NULL,
p_resource_name VARCHAR DEFAULT NULL,
p_description TEXT DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_changed_fields JSONB DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'::jsonb,
p_severity audit.severity DEFAULT 'info'
)
RETURNS UUID AS $$
DECLARE
v_id UUID;
v_user_email VARCHAR(255);
BEGIN
-- Get user email for denormalization
SELECT email INTO v_user_email
FROM users.users
WHERE id = p_user_id;
INSERT INTO audit.audit_logs (
tenant_id, user_id, user_email, action, resource_type, resource_id,
resource_name, description, old_values, new_values, changed_fields,
metadata, severity
) VALUES (
p_tenant_id, p_user_id, v_user_email, p_action, p_resource_type, p_resource_id,
p_resource_name, p_description, p_old_values, p_new_values, p_changed_fields,
p_metadata, p_severity
) RETURNING id INTO v_id;
RETURN v_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================
-- 12. Update comments
-- ============================================
COMMENT ON COLUMN audit.audit_logs.user_email IS 'Denormalized user email for fast queries';
COMMENT ON COLUMN audit.audit_logs.actor_type IS 'Type of actor: user, system, api_key, webhook';
COMMENT ON COLUMN audit.audit_logs.resource_type IS 'Type of resource: user, product, invoice, etc.';
COMMENT ON COLUMN audit.audit_logs.resource_id IS 'ID of the affected resource';
COMMENT ON COLUMN audit.audit_logs.resource_name IS 'Human-readable name of the resource';
COMMENT ON COLUMN audit.audit_logs.severity IS 'Log severity level: info, warning, error, critical';
COMMENT ON COLUMN audit.audit_logs.request_id IS 'Correlation ID for request tracing';
COMMENT ON COLUMN audit.audit_logs.session_id IS 'Session ID for session correlation';
COMMENT ON COLUMN audit.audit_logs.endpoint IS 'API endpoint path';
COMMENT ON COLUMN audit.audit_logs.http_method IS 'HTTP method: GET, POST, PUT, DELETE, etc.';
COMMENT ON COLUMN audit.audit_logs.response_status IS 'HTTP response status code';
COMMENT ON COLUMN audit.audit_logs.duration_ms IS 'Request duration in milliseconds';
COMMENT ON COLUMN audit.audit_logs.old_values IS 'Previous values before change';
COMMENT ON COLUMN audit.audit_logs.new_values IS 'New values after change';
COMMENT ON COLUMN audit.audit_logs.changed_fields IS 'List of fields that were changed';
COMMIT;