diff --git a/supabase/migrations/004_audit_logging.sql b/supabase/migrations/004_audit_logging.sql new file mode 100644 index 0000000..11bc401 --- /dev/null +++ b/supabase/migrations/004_audit_logging.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- FreightDesk — Migration 004: Audit Logging +-- Adapted from Hermes agent's soft-delete-audit branch +-- Adds audit_logs table + triggers for core tables +-- ============================================================ + +-- ============================================================ +-- AUDIT LOG TABLE +-- ============================================================ +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' + table_name TEXT NOT NULL, -- e.g., 'loads', 'shippers' + row_id UUID, -- UUID of the affected row + before_json JSONB, -- state before change + after_json JSONB, -- state after change + created_at TIMESTAMPTZ DEFAULT NOW(), + user_id UUID, -- admin who made change (nullable) + notes TEXT +); + +-- Indexes for fast lookup +CREATE INDEX IF NOT EXISTS idx_audit_logs_table ON audit_logs(table_name); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_time ON audit_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_row ON audit_logs(table_name, row_id); + +-- ============================================================ +-- AUDIT TRIGGER FUNCTION +-- Captures INSERT, UPDATE, DELETE on core tables +-- Uses PostgreSQL session variable app.current_user_id for user tracking +-- ============================================================ +CREATE OR REPLACE FUNCTION fn_audit_trigger() +RETURNS TRIGGER AS $$ +DECLARE + v_user_id UUID; +BEGIN + -- Get user_id from session variable (set by app before DB ops) + BEGIN + v_user_id := current_setting('app.current_user_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_user_id := NULL; + END; + + IF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs(action, table_name, row_id, after_json, user_id) + VALUES ('INSERT', TG_TABLE_NAME, NEW.id, to_jsonb(NEW), v_user_id); + RETURN NEW; + + ELSIF TG_OP = 'UPDATE' THEN + -- Skip if only deleted_at changed (soft-delete is logged separately) + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id, notes) + VALUES ('SOFT_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id, 'Soft delete via trigger'); + ELSE + INSERT INTO audit_logs(action, table_name, row_id, before_json, after_json, user_id) + VALUES ('UPDATE', TG_TABLE_NAME, NEW.id, to_jsonb(OLD), to_jsonb(NEW), v_user_id); + END IF; + RETURN NEW; + + ELSIF TG_OP = 'DELETE' THEN + INSERT INTO audit_logs(action, table_name, row_id, before_json, user_id, notes) + VALUES ('HARD_DELETE', TG_TABLE_NAME, OLD.id, to_jsonb(OLD), v_user_id, 'Hard delete (bypasses soft-delete)'); + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- ATTACH TRIGGERS TO CORE TABLES +-- ============================================================ +DROP TRIGGER IF EXISTS trg_audit_loads ON loads; +CREATE TRIGGER trg_audit_loads + AFTER INSERT OR UPDATE OR DELETE ON loads + FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger(); + +DROP TRIGGER IF EXISTS trg_audit_shippers ON shippers; +CREATE TRIGGER trg_audit_shippers + AFTER INSERT OR UPDATE OR DELETE ON shippers + FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger(); + +DROP TRIGGER IF EXISTS trg_audit_vehicles ON vehicles; +CREATE TRIGGER trg_audit_vehicles + AFTER INSERT OR UPDATE OR DELETE ON vehicles + FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger(); + +DROP TRIGGER IF EXISTS trg_audit_payments ON payments; +CREATE TRIGGER trg_audit_payments + AFTER INSERT OR UPDATE OR DELETE ON payments + FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger(); + +DROP TRIGGER IF EXISTS trg_audit_portal_users ON portal_users; +CREATE TRIGGER trg_audit_portal_users + AFTER INSERT OR UPDATE OR DELETE ON portal_users + FOR EACH ROW EXECUTE FUNCTION fn_audit_trigger(); + +-- ============================================================ +-- HELPER: Function to set current user for audit context +-- Call this from Express middleware before DB operations: +-- await supabase.rpc('set_audit_user', { user_id: req.session.user.id }) +-- ============================================================ +CREATE OR REPLACE FUNCTION set_audit_user(user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM set_config('app.current_user_id', user_id::TEXT, false); +END; +$$ LANGUAGE plpgsql; diff --git a/webapp/src/routes/audit.js b/webapp/src/routes/audit.js new file mode 100644 index 0000000..0b0e2b6 --- /dev/null +++ b/webapp/src/routes/audit.js @@ -0,0 +1,50 @@ +const express = require('express'); +const router = express.Router(); +const { requireAuth, requireRole } = require('../middleware/auth'); +const supabase = require('../services/supabase'); +const { asyncHandler } = require('../middleware/security'); + +// GET /audit-logs — view audit trail (admin only) +router.get('/', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { + const { table, user_id, action, page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + let query = supabase + .from('audit_logs') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (table) query = query.eq('table_name', table); + if (user_id) query = query.eq('user_id', user_id); + if (action) query = query.eq('action', action); + + const { data: logs, count, error } = await query; + + if (error) throw error; + + const totalPages = Math.ceil(count / limit); + + res.render('pages/audit/list', { + logs: logs || [], + page: parseInt(page), + totalPages, + total: count, + filters: { table, user_id, action }, + }); +})); + +// GET /audit-logs/:id — single log detail +router.get('/:id', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { + const { data: log, error } = await supabase + .from('audit_logs') + .select('*') + .eq('id', req.params.id) + .single(); + + if (error) throw error; + + res.render('pages/audit/detail', { log }); +})); + +module.exports = router; diff --git a/webapp/src/server.js b/webapp/src/server.js index 394837f..bc80c9e 100644 --- a/webapp/src/server.js +++ b/webapp/src/server.js @@ -204,6 +204,7 @@ app.use('/shippers', require('./routes/shippers')); app.use('/vehicles', require('./routes/vehicles')); app.use('/payments', require('./routes/payments')); app.use('/reports', require('./routes/reports')); +app.use('/audit-logs', require('./routes/audit')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); diff --git a/webapp/src/views/pages/audit/detail.ejs b/webapp/src/views/pages/audit/detail.ejs new file mode 100644 index 0000000..7c526af --- /dev/null +++ b/webapp/src/views/pages/audit/detail.ejs @@ -0,0 +1,68 @@ +<%- include('../partials/header', { activeMenu: 'audit' }) %> + +
<%= log.id %>
+<%= log.table_name %>
+ <%= log.row_id || '—' %>
+ <%= log.user_id || 'System' %>
+ <%= JSON.stringify(log.before_json, null, 2) %>+
<%= JSON.stringify(log.after_json, null, 2) %>+
Track all changes across the platform
+No audit logs found matching your filters.
+ <% } else { %> +Showing <%= logs.length %> of <%= total %> logs (page <%= page %> of <%= totalPages %>)
+| Time | +Action | +Table | +Row ID | +Details | +
|---|---|---|---|---|
| <%= new Date(log.created_at).toLocaleString('en-IN', { dateStyle: 'short', timeStyle: 'short' }) %> | ++ + <%= log.action %> + + | +<%= log.table_name %> |
+ <%= log.row_id ? log.row_id.slice(0,8) + '...' : '—' %> |
+ View | +