388 lines
13 KiB
PL/PgSQL
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';
|