trading-platform-database-v2/ddl/schemas/audit/tables/001_audit_logs.sql
rckrdmrd e520268348 Migración desde trading-platform/apps/database - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:32:52 -06:00

388 lines
13 KiB
PL/PgSQL

-- ============================================================================
-- Audit Schema: Audit Logs Table
-- Comprehensive audit trail for Trading Platform SaaS
-- ============================================================================
-- Create audit schema if not exists
CREATE SCHEMA IF NOT EXISTS audit;
-- Grant usage
GRANT USAGE ON SCHEMA audit TO trading_user;
-- ============================================================================
-- AUDIT_LOGS TABLE
-- Immutable audit trail for all significant actions
-- ============================================================================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
-- Primary key (using BIGSERIAL for high volume)
id BIGSERIAL PRIMARY KEY,
-- Event identification
event_id UUID NOT NULL DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
event_category VARCHAR(50) NOT NULL,
-- Actor information (who performed the action)
actor_id UUID,
actor_email VARCHAR(255),
actor_type VARCHAR(20) NOT NULL DEFAULT 'user'
CHECK (actor_type IN ('user', 'system', 'api', 'webhook', 'scheduled')),
actor_ip INET,
actor_user_agent TEXT,
-- Tenant context
tenant_id UUID,
-- Target resource
resource_type VARCHAR(100) NOT NULL,
resource_id VARCHAR(255),
resource_name VARCHAR(255),
-- Action details
action VARCHAR(50) NOT NULL,
action_result VARCHAR(20) NOT NULL DEFAULT 'success'
CHECK (action_result IN ('success', 'failure', 'partial', 'pending')),
-- Change tracking
previous_state JSONB,
new_state JSONB,
changes JSONB, -- Computed diff between previous and new state
-- Additional context
metadata JSONB DEFAULT '{}'::jsonb,
request_id VARCHAR(100),
session_id UUID,
-- Error information (for failures)
error_code VARCHAR(50),
error_message TEXT,
-- Timestamps
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Retention marker
is_archived BOOLEAN NOT NULL DEFAULT false,
archived_at TIMESTAMPTZ
);
-- ============================================================================
-- INDEXES
-- Optimized for common query patterns
-- ============================================================================
-- Time-based queries (most common)
CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred_at ON audit.audit_logs(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_time ON audit.audit_logs(tenant_id, occurred_at DESC);
-- Actor queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_id ON audit.audit_logs(actor_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_email ON audit.audit_logs(actor_email);
-- Resource queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_tenant ON audit.audit_logs(tenant_id, resource_type, occurred_at DESC);
-- Event type queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit.audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_logs_event_category ON audit.audit_logs(event_category);
-- Action queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_result ON audit.audit_logs(action_result) WHERE action_result = 'failure';
-- Request tracking
CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit.audit_logs(request_id) WHERE request_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_session_id ON audit.audit_logs(session_id) WHERE session_id IS NOT NULL;
-- Archival
CREATE INDEX IF NOT EXISTS idx_audit_logs_archived ON audit.audit_logs(is_archived, occurred_at);
-- ============================================================================
-- PARTITIONING (for high-volume deployments)
-- Uncomment to enable monthly partitions
-- ============================================================================
-- CREATE TABLE audit.audit_logs_partitioned (
-- LIKE audit.audit_logs INCLUDING ALL
-- ) PARTITION BY RANGE (occurred_at);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see audit logs in their tenant
-- Note: System admins may need broader access
CREATE POLICY audit_logs_tenant_isolation ON audit.audit_logs
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
OR current_setting('app.is_system_admin', true)::boolean = true
);
-- Policy: Insert is allowed for logging (no tenant check on insert)
CREATE POLICY audit_logs_insert ON audit.audit_logs
FOR INSERT
WITH CHECK (true);
-- ============================================================================
-- FUNCTION: Log audit event
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.log_event(
p_event_type VARCHAR(100),
p_event_category VARCHAR(50),
p_action VARCHAR(50),
p_resource_type VARCHAR(100),
p_resource_id VARCHAR(255) DEFAULT NULL,
p_resource_name VARCHAR(255) DEFAULT NULL,
p_actor_id UUID DEFAULT NULL,
p_actor_email VARCHAR(255) DEFAULT NULL,
p_actor_type VARCHAR(20) DEFAULT 'user',
p_actor_ip INET DEFAULT NULL,
p_tenant_id UUID DEFAULT NULL,
p_previous_state JSONB DEFAULT NULL,
p_new_state JSONB DEFAULT NULL,
p_action_result VARCHAR(20) DEFAULT 'success',
p_metadata JSONB DEFAULT '{}'::jsonb,
p_request_id VARCHAR(100) DEFAULT NULL,
p_session_id UUID DEFAULT NULL,
p_error_code VARCHAR(50) DEFAULT NULL,
p_error_message TEXT DEFAULT NULL
)
RETURNS BIGINT AS $$
DECLARE
v_log_id BIGINT;
v_changes JSONB;
BEGIN
-- Compute changes if both states provided
IF p_previous_state IS NOT NULL AND p_new_state IS NOT NULL THEN
v_changes := audit.compute_json_diff(p_previous_state, p_new_state);
END IF;
INSERT INTO audit.audit_logs (
event_type, event_category, action,
resource_type, resource_id, resource_name,
actor_id, actor_email, actor_type, actor_ip,
tenant_id,
previous_state, new_state, changes,
action_result, metadata,
request_id, session_id,
error_code, error_message
) VALUES (
p_event_type, p_event_category, p_action,
p_resource_type, p_resource_id, p_resource_name,
p_actor_id, p_actor_email, p_actor_type, p_actor_ip,
p_tenant_id,
p_previous_state, p_new_state, v_changes,
p_action_result, p_metadata,
p_request_id, p_session_id,
p_error_code, p_error_message
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- FUNCTION: Compute JSON diff
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.compute_json_diff(
p_old JSONB,
p_new JSONB
)
RETURNS JSONB AS $$
DECLARE
v_key TEXT;
v_diff JSONB := '{}'::jsonb;
BEGIN
-- Find changed and added keys
FOR v_key IN SELECT jsonb_object_keys(p_new)
LOOP
IF NOT p_old ? v_key THEN
-- New key added
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'added',
'new', p_new->v_key
));
ELSIF p_old->v_key != p_new->v_key THEN
-- Key modified
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'modified',
'old', p_old->v_key,
'new', p_new->v_key
));
END IF;
END LOOP;
-- Find deleted keys
FOR v_key IN SELECT jsonb_object_keys(p_old)
LOOP
IF NOT p_new ? v_key THEN
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_object(
'action', 'deleted',
'old', p_old->v_key
));
END IF;
END LOOP;
RETURN v_diff;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- ============================================================================
-- CONVENIENCE FUNCTIONS FOR COMMON EVENTS
-- ============================================================================
-- Log authentication event
CREATE OR REPLACE FUNCTION audit.log_auth_event(
p_action VARCHAR(50), -- 'login', 'logout', 'login_failed', 'password_changed', etc.
p_user_id UUID,
p_user_email VARCHAR(255),
p_tenant_id UUID,
p_ip INET DEFAULT NULL,
p_success BOOLEAN DEFAULT true,
p_metadata JSONB DEFAULT '{}'::jsonb,
p_error_message TEXT DEFAULT NULL
)
RETURNS BIGINT AS $$
BEGIN
RETURN audit.log_event(
'auth.' || p_action,
'authentication',
p_action,
'user',
p_user_id::text,
p_user_email,
p_user_id,
p_user_email,
'user',
p_ip,
p_tenant_id,
NULL, NULL,
CASE WHEN p_success THEN 'success' ELSE 'failure' END,
p_metadata,
NULL, NULL,
CASE WHEN NOT p_success THEN 'AUTH_FAILED' ELSE NULL END,
p_error_message
);
END;
$$ LANGUAGE plpgsql;
-- Log resource CRUD event
CREATE OR REPLACE FUNCTION audit.log_resource_event(
p_action VARCHAR(50), -- 'created', 'updated', 'deleted', 'viewed'
p_resource_type VARCHAR(100),
p_resource_id VARCHAR(255),
p_resource_name VARCHAR(255),
p_actor_id UUID,
p_actor_email VARCHAR(255),
p_tenant_id UUID,
p_previous_state JSONB DEFAULT NULL,
p_new_state JSONB DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'::jsonb
)
RETURNS BIGINT AS $$
BEGIN
RETURN audit.log_event(
p_resource_type || '.' || p_action,
'resource',
p_action,
p_resource_type,
p_resource_id,
p_resource_name,
p_actor_id,
p_actor_email,
'user',
NULL,
p_tenant_id,
p_previous_state,
p_new_state,
'success',
p_metadata
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- VIEW: Recent audit logs
-- ============================================================================
CREATE OR REPLACE VIEW audit.v_recent_logs AS
SELECT
id,
event_id,
event_type,
event_category,
actor_email,
actor_type,
actor_ip,
tenant_id,
resource_type,
resource_id,
resource_name,
action,
action_result,
changes,
metadata,
error_message,
occurred_at
FROM audit.audit_logs
WHERE occurred_at > CURRENT_TIMESTAMP - INTERVAL '30 days'
AND is_archived = false
ORDER BY occurred_at DESC;
-- ============================================================================
-- FUNCTION: Archive old logs
-- ============================================================================
CREATE OR REPLACE FUNCTION audit.archive_old_logs(
p_older_than_days INTEGER DEFAULT 90
)
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE audit.audit_logs
SET is_archived = true,
archived_at = CURRENT_TIMESTAMP
WHERE is_archived = false
AND occurred_at < CURRENT_TIMESTAMP - (p_older_than_days || ' days')::interval;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- GRANTS
-- ============================================================================
GRANT SELECT ON audit.audit_logs TO trading_user;
GRANT INSERT ON audit.audit_logs TO trading_user;
GRANT SELECT ON audit.v_recent_logs TO trading_user;
GRANT USAGE, SELECT ON SEQUENCE audit.audit_logs_id_seq TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_auth_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.log_resource_event TO trading_user;
GRANT EXECUTE ON FUNCTION audit.compute_json_diff TO trading_user;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE audit.audit_logs IS 'Immutable audit trail for all significant actions';
COMMENT ON COLUMN audit.audit_logs.event_type IS 'Full event type e.g., auth.login, user.created';
COMMENT ON COLUMN audit.audit_logs.event_category IS 'High-level category: authentication, resource, system';
COMMENT ON COLUMN audit.audit_logs.actor_type IS 'Type of actor: user, system, api, webhook, scheduled';
COMMENT ON COLUMN audit.audit_logs.changes IS 'Computed diff between previous_state and new_state';
COMMENT ON COLUMN audit.audit_logs.is_archived IS 'Marker for log retention/archival';
COMMENT ON FUNCTION audit.log_event IS 'Main function to log any audit event';
COMMENT ON FUNCTION audit.log_auth_event IS 'Convenience function for authentication events';
COMMENT ON FUNCTION audit.log_resource_event IS 'Convenience function for resource CRUD events';