-- ============================================================================ -- 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';