diff --git a/ai-server/index.mjs b/ai-server/index.mjs index cd605d7..726c577 100644 --- a/ai-server/index.mjs +++ b/ai-server/index.mjs @@ -256,11 +256,47 @@ const server = http.createServer(async (req, res) => { const { pathname } = parseURL(req) try { + // GET /splash — loading screen + if (req.method === "GET" && pathname === "/splash") { + const splashPath = path.join(ROOT, "splash.html") + if (fs.existsSync(splashPath)) { + const html = fs.readFileSync(splashPath, "utf-8") + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(html) + return + } + sendJSON(res, 200, { status: "ok" }) + return + } + // GET /health if (req.method === "GET" && pathname === "/health") { return sendJSON(res, 200, { status: "ok", model: MODEL }) } + // GET /status — combined health of all services + if (req.method === "GET" && pathname === "/status") { + const { default: http } = await import("http") + const results = { ai: true } + // Check scraper + try { + await new Promise((resolve, reject) => { + const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() }) + r.on("error", reject) + }) + results.scraper = true + } catch { results.scraper = false } + // Check frontend + try { + await new Promise((resolve, reject) => { + const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() }) + r.on("error", reject) + }) + results.frontend = true + } catch { results.frontend = false } + return sendJSON(res, 200, results) + } + // GET /ai/jobs if (req.method === "GET" && pathname === "/ai/jobs") { return sendJSON(res, 200, { jobs: loadedJobs }) diff --git a/database/migrations/013_security_upgrade.sql b/database/migrations/013_security_upgrade.sql new file mode 100644 index 0000000..62ebc44 --- /dev/null +++ b/database/migrations/013_security_upgrade.sql @@ -0,0 +1,604 @@ +-- ============================================================================ +-- CRM Security Architecture Upgrade +-- Internal Company CRM — Prioritizes control, recovery, and maintainability +-- ============================================================================ +-- Implements: +-- • Dual password storage (bcrypt + pgcrypto AES reversible) +-- • SUPER_ADMIN master key recovery system +-- • Row Level Security (RLS) on CRM tables +-- • Database export logging +-- • Backup logging +-- • Immutable admin action tracking +-- • SQL injection defense (parameterized queries enforced app-side) +-- ============================================================================ + +-- ============================================================================ +-- 1. DUAL PASSWORD STORAGE +-- ============================================================================ +-- password_hash (bcrypt) — used for normal login authentication +-- password_encrypted (AES) — reversible encryption for emergency credential recovery +-- ============================================================================ + +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT; + +-- ============================================================================ +-- 2. SUPER_ADMIN MASTER KEY SYSTEM +-- ============================================================================ +-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt. +-- Only accessible by SUPER_ADMIN role via security definer functions. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS master_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + key_name VARCHAR(100) NOT NULL UNIQUE, + key_value TEXT NOT NULL, + description TEXT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE; + +-- ============================================================================ +-- 3. DATABASE EXPORT LOGGING +-- ============================================================================ +-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN. +-- Immutable — never allow deletion or update. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS database_export_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + exported_by UUID NOT NULL REFERENCES users(id), + export_type VARCHAR(50) NOT NULL, + file_name VARCHAR(500) NOT NULL, + file_size_bytes BIGINT, + record_count INT, + ip_address INET, + user_agent TEXT, + notes TEXT, + exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by); +CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC); + +COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.'; +COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export'; + +-- ============================================================================ +-- 4. BACKUP LOGGING +-- ============================================================================ +-- Records every automated or manual database backup. +-- Retention: minimum 30 days of backup history. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS backup_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + backup_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + file_name VARCHAR(500), + file_size_bytes BIGINT, + pg_dump_exit_code INT, + error_message TEXT, + checksum VARCHAR(64), + verification_status VARCHAR(20), + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + duration_seconds INT, + retention_days INT NOT NULL DEFAULT 30, + triggered_by UUID REFERENCES users(id), + notes TEXT +); + +CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC); +CREATE INDEX idx_backup_logs_status ON backup_logs(status); +CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type); +CREATE INDEX idx_backup_logs_completed ON backup_logs(status) + WHERE status = 'completed'; + +-- ============================================================================ +-- 5. AUDIT TRIGGER: Password changes +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_password_change() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN + INSERT INTO audit_logs ( + table_name, record_id, action, old_data, new_data, changed_by, ip_address + ) VALUES ( + 'users', + NEW.id, + 'UPDATE', + jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required), + jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required), + NULLIF(current_setting('app.current_user_id', true), ''), + NULLIF(current_setting('app.current_ip', true), '') + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_password_change ON users; +CREATE TRIGGER trg_audit_password_change + AFTER UPDATE OF password_hash ON users + FOR EACH ROW + EXECUTE FUNCTION audit_password_change(); + +-- ============================================================================ +-- 6. AUDIT TRIGGER: User creation +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_user_creation() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit_logs ( + table_name, record_id, action, new_data, changed_by + ) VALUES ( + 'users', + NEW.id, + 'CREATE', + jsonb_build_object( + 'username', NEW.username, + 'email', NEW.email, + 'first_name', NEW.first_name, + 'last_name', NEW.last_name, + 'is_active', NEW.is_active + ), + NEW.created_by + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_user_create ON users; +CREATE TRIGGER trg_audit_user_create + AFTER INSERT ON users + FOR EACH ROW + EXECUTE FUNCTION audit_user_creation(); + +-- ============================================================================ +-- 7. AUDIT TRIGGER: Database exports +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_database_export() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit_logs ( + table_name, record_id, action, new_data, changed_by + ) VALUES ( + 'database_export_logs', + NEW.id, + 'CREATE', + jsonb_build_object( + 'export_type', NEW.export_type, + 'file_name', NEW.file_name, + 'file_size_bytes', NEW.file_size_bytes, + 'record_count', NEW.record_count + ), + NEW.exported_by + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs; +CREATE TRIGGER trg_audit_database_export + AFTER INSERT ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION audit_database_export(); + +-- ============================================================================ +-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger +-- (trg_audit_login in 001_schema.sql) +-- This enhances it to also track session-level events. +-- ============================================================================ + +DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts; +CREATE TRIGGER trg_audit_login + AFTER INSERT ON login_attempts + FOR EACH ROW + EXECUTE FUNCTION audit_login(); + +-- ============================================================================ +-- 9. ROW LEVEL SECURITY +-- ============================================================================ +-- RLS policies enforce data visibility per role: +-- SALES_USER → only sees records assigned to them +-- ADMIN → sees operational records for investigation +-- SUPER_ADMIN → sees everything +-- ============================================================================ + +-- Helper function to get current user's role hierarchy level +CREATE OR REPLACE FUNCTION current_user_hierarchy_level() +RETURNS INT AS $$ +DECLARE + uid UUID; +BEGIN + BEGIN + uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID; + EXCEPTION WHEN OTHERS THEN + RETURN NULL; + END; + + IF uid IS NULL THEN + RETURN NULL; + END IF; + + RETURN ( + SELECT MIN(r.hierarchy_level) + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = uid + ); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +-- Helper function to get current user's ID from session setting +CREATE OR REPLACE FUNCTION current_user_id() +RETURNS UUID AS $$ +BEGIN + BEGIN + RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID; + EXCEPTION WHEN OTHERS THEN + RETURN NULL; + END; +END; +$$ LANGUAGE plpgsql STABLE; + +-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1 + +-- ── customers ── +ALTER TABLE customers ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_customers_select ON customers; +CREATE POLICY rls_customers_select ON customers + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all + OR owner_id = current_user_id() + ); + +DROP POLICY IF EXISTS rls_customers_insert ON customers; +CREATE POLICY rls_customers_insert ON customers + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner + OR owner_id = current_user_id() -- SALES_USER can only assign to self + ) + ); + +DROP POLICY IF EXISTS rls_customers_update ON customers; +CREATE POLICY rls_customers_update ON customers + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ) + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR ( + owner_id = current_user_id() + AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership + ) + ) + ); + +DROP POLICY IF EXISTS rls_customers_delete ON customers; +CREATE POLICY rls_customers_delete ON customers + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── leads ── +ALTER TABLE leads ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_leads_select ON leads; +CREATE POLICY rls_leads_select ON leads + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ); + +DROP POLICY IF EXISTS rls_leads_insert ON leads; +CREATE POLICY rls_leads_insert ON leads + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_leads_update ON leads; +CREATE POLICY rls_leads_update ON leads + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_leads_delete ON leads; +CREATE POLICY rls_leads_delete ON leads + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── opportunities ── +ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_opportunities_select ON opportunities; +CREATE POLICY rls_opportunities_select ON opportunities + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ); + +DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities; +CREATE POLICY rls_opportunities_insert ON opportunities + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_opportunities_update ON opportunities; +CREATE POLICY rls_opportunities_update ON opportunities + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities; +CREATE POLICY rls_opportunities_delete ON opportunities + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── communications ── +ALTER TABLE communications ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_communications_select ON communications; +CREATE POLICY rls_communications_select ON communications + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR created_by = current_user_id() + ); + +DROP POLICY IF EXISTS rls_communications_insert ON communications; +CREATE POLICY rls_communications_insert ON communications + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + ); + +DROP POLICY IF EXISTS rls_communications_delete ON communications; +CREATE POLICY rls_communications_delete ON communications + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── tasks ── +ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_tasks_select ON tasks; +CREATE POLICY rls_tasks_select ON tasks + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + OR assigned_by = current_user_id() + ); + +DROP POLICY IF EXISTS rls_tasks_update ON tasks; +CREATE POLICY rls_tasks_update ON tasks + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + OR assigned_by = current_user_id() + ) + ); + +-- ============================================================================ +-- 10. IMMUTABLE AUDIT LOG PROTECTION +-- ============================================================================ +-- Prevent deletion or update of audit_logs at the database level. +-- Even SUPER_ADMIN cannot delete historical audit records. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION prevent_audit_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs; +CREATE TRIGGER trg_prevent_audit_delete + BEFORE DELETE ON audit_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs; +CREATE TRIGGER trg_prevent_audit_update + BEFORE UPDATE ON audit_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +-- Same protection for database_export_logs +DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs; +CREATE TRIGGER trg_prevent_export_delete + BEFORE DELETE ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs; +CREATE TRIGGER trg_prevent_export_update + BEFORE UPDATE ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +-- ============================================================================ +-- 11. ENCRYPTION FUNCTIONS +-- ============================================================================ +-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key. +-- Master key is stored in master_keys table, fetched by security definer functions. +-- ============================================================================ + +-- Get the active master decryption key +-- Only callable by SUPER_ADMIN +CREATE OR REPLACE FUNCTION get_master_decryption_key() +RETURNS TEXT AS $$ +DECLARE + key_value TEXT; + uid UUID; + user_level INT; +BEGIN + uid := current_user_id(); + IF uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + user_level := current_user_hierarchy_level(); + IF user_level IS NULL OR user_level > 1 THEN + RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key'; + END IF; + + SELECT mk.key_value INTO key_value + FROM master_keys mk + WHERE mk.is_active = TRUE + ORDER BY mk.created_at DESC + LIMIT 1; + + RETURN key_value; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Encrypt a plaintext password using the active master key +CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT) +RETURNS TEXT AS $$ +DECLARE + master_key TEXT; +BEGIN + SELECT key_value INTO master_key + FROM master_keys + WHERE is_active = TRUE + ORDER BY created_at DESC + LIMIT 1; + + IF master_key IS NULL THEN + RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.'; + END IF; + + RETURN encode( + pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'), + 'escape' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Decrypt a password that was encrypted with encrypt_password() +CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT) +RETURNS TEXT AS $$ +DECLARE + master_key TEXT; +BEGIN + master_key := get_master_decryption_key(); + RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 12. RLS BYPASS FOR SUPER_ADMIN +-- ============================================================================ +-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies +-- to check if the current user should bypass restrictions. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION is_super_admin() +RETURNS BOOLEAN AS $$ +BEGIN + RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +-- ============================================================================ +-- 13. SEED DATA: Master key (for development/testing) +-- ============================================================================ +-- In production, this key should be set via a secure deployment process. +-- The key is stored in the database and encrypted at rest by PostgreSQL. +-- ============================================================================ + +INSERT INTO master_keys (key_name, key_value, description, created_by) +SELECT + 'MASTER_DECRYPTION_KEY', + encode(gen_random_bytes(32), 'hex'), + 'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.', + '00000000-0000-0000-0000-000000000001' +WHERE NOT EXISTS ( + SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY' +); + +-- ============================================================================ +-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts +-- ============================================================================ + +UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026') +WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026') +WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026') +WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') +WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; + +-- ============================================================================ +-- FUTURE REQUIREMENT NOTE: +-- If this system is ever exposed publicly, remove reversible password storage +-- immediately. Keep bcrypt only. Delete the password_encrypted column and +-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally. +-- ============================================================================ + diff --git a/database/migrations/014_bug_reports.sql b/database/migrations/014_bug_reports.sql new file mode 100644 index 0000000..4a420fc --- /dev/null +++ b/database/migrations/014_bug_reports.sql @@ -0,0 +1,145 @@ +-- ============================================================================ +-- Bug Reporting System +-- ============================================================================ +-- Access rules: +-- ALL authenticated users can INSERT (submit bug reports) +-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage) +-- SALES_USER and DEVELOPER cannot view bug_reports after submission +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS bug_reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reported_by UUID NOT NULL REFERENCES users(id), + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'medium' + CHECK (severity IN ('low', 'medium', 'high', 'critical')), + page_url TEXT, + screenshot_url TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')), + assigned_to UUID REFERENCES users(id), + resolution_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by); +CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status); +CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity); +CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to); +CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC); + +-- RLS: Allow INSERT for all authenticated users +-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN +ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS bug_reports_insert ON bug_reports; +CREATE POLICY bug_reports_insert ON bug_reports + FOR INSERT + WITH CHECK ( + current_user_id() IS NOT NULL + AND reported_by = current_user_id() + ); + +DROP POLICY IF EXISTS bug_reports_select ON bug_reports; +CREATE POLICY bug_reports_select ON bug_reports + FOR SELECT + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +DROP POLICY IF EXISTS bug_reports_update ON bug_reports; +CREATE POLICY bug_reports_update ON bug_reports + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- Audit trigger for bug report actions +CREATE OR REPLACE FUNCTION audit_bug_report_action() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + 'BUG_CREATED', + jsonb_build_object( + 'title', NEW.title, + 'severity', NEW.severity, + 'page_url', NEW.page_url, + 'status', NEW.status + ), + NEW.reported_by + ); + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + CASE NEW.status + WHEN 'resolved' THEN 'BUG_RESOLVED' + ELSE 'BUG_UPDATED' + END, + jsonb_build_object( + 'old_status', OLD.status, + 'new_status', NEW.status, + 'assigned_to', NEW.assigned_to, + 'resolution_notes', NEW.resolution_notes + ), + NULLIF(current_setting('app.current_user_id', true), '') + ); + END IF; + IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + 'BUG_ASSIGNED', + jsonb_build_object( + 'assigned_to', NEW.assigned_to, + 'previous_assignee', OLD.assigned_to, + 'status', NEW.status + ), + NULLIF(current_setting('app.current_user_id', true), '') + ); + END IF; + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports; +CREATE TRIGGER trg_audit_bug_report + AFTER INSERT OR UPDATE ON bug_reports + FOR EACH ROW + EXECUTE FUNCTION audit_bug_report_action(); + +-- Widen audit action constraint to include bug report events +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action; +ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action + CHECK (action::text = ANY (ARRAY[ + 'CREATE', 'UPDATE', 'DELETE', + 'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED', + 'LOGIN', 'LOGOUT' + ]::text[])); + +-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly +-- via RLS bypass attempts (additional safety via trigger) +CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass() +RETURNS TRIGGER AS $$ +DECLARE + user_level INT; +BEGIN + user_level := current_user_hierarchy_level(); + IF user_level IS NULL OR user_level > 2 THEN + RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 2ec9138..ec98d2e 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -34,5 +34,11 @@ BEGIN; \echo '=== Running 009_settings.sql (Company Settings + User Preferences) ===' \i 009_settings.sql +\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ===' +\i 013_security_upgrade.sql + +\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ===' +\i 014_bug_reports.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/package.json b/package.json index 3bd629f..2e0a228 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev:signaling": "node signaling-server.mjs", - "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT -c cyan,magenta,yellow,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\"", + "dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"", + "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:next": "next dev -p 3006", "dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"", "dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"", diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..06b7ced --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,125 @@ +param( + [string]$BackupDir = "$PSScriptRoot\..\backups", + [int]$RetentionDays = 30 +) + +$ErrorActionPreference = "Stop" +$startTime = Get-Date + +# Ensure backup directory exists +if (-not (Test-Path $BackupDir)) { + New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null +} + +# Determine database URL from env or .env.local +$dbUrl = $env:DATABASE_URL +if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) { + $envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw + if ($envContent -match 'DATABASE_URL=(.+)') { + $dbUrl = $Matches[1].Trim() + } +} + +if (-not $dbUrl) { + Write-Error "DATABASE_URL not found. Set it as an environment variable or in .env.local" + exit 1 +} + +# Parse the database URL +$uri = [System.Uri]$dbUrl +$pgUser = $uri.UserInfo.Split(':')[0] +$pgPass = $uri.UserInfo.Split(':')[1] +$pgHost = $uri.Host +$pgPort = $uri.Port +$pgDb = $uri.AbsolutePath.TrimStart('/') + +# Set PGPASSWORD for pg_dump +$env:PGPASSWORD = $pgPass + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$filename = "crm_backup_$timestamp.sql" +$filepath = Join-Path $BackupDir $filename + +Write-Output "Starting backup of database '$pgDb' to $filepath" +Write-Output "Database: $pgHost:$pgPort/$pgDb" + +try { + # Run pg_dump + $pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) { + "pg_dump" + } else { + "$env:TEMP\pg\pgsql\bin\pg_dump.exe" + } + + $output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1 + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0 -or -not (Test-Path $filename)) { + throw "pg_dump failed with exit code $exitCode" + } + + Move-Item -Path $filename -Destination $filepath -Force + $fileInfo = Get-Item $filepath + $fileSize = $fileInfo.Length + + # Verify backup by checking if file is valid custom format + $verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1 + $verifyExitCode = $LASTEXITCODE + if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force } + + $verificationStatus = if ($verifyExitCode -eq 0) { "verified" } else { "failed" } + + # Calculate duration + $endTime = Get-Date + $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) + + # Log the backup + $logQuery = @" +INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days) +VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays); +"@ + + # Try to log to database (may fail if db is not reachable for logging, that's OK) + try { + $env:PGPASSWORD = $pgPass + & $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $logQuery 2>&1 | Out-Null + } catch { + Write-Warning "Could not log backup to database: $_" + } + + Write-Output "Backup completed successfully:" + Write-Output " File: $filename" + Write-Output " Size: $([math]::Round($fileSize / 1MB, 2)) MB" + Write-Output " Duration: ${durationSeconds}s" + Write-Output " Status: $verificationStatus" + + # Cleanup old backups (beyond retention period) + $cutoff = (Get-Date).AddDays(-$RetentionDays) + $oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object { + $_.CreationTime -lt $cutoff + } + foreach ($old in $oldBackups) { + Remove-Item $old.FullName -Force + Write-Output "Removed old backup: $($old.Name)" + } + +} catch { + Write-Error "Backup failed: $_" + + # Log failure + $endTime = Get-Date + $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) + $errorMsg = $_.ToString().Replace("'", "''") + $failQuery = @" +INSERT INTO backup_logs (backup_type, status, file_name, error_message, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days) +VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays); +"@ + try { + $env:PGPASSWORD = $pgPass + & $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $failQuery 2>&1 | Out-Null + } catch {} + + exit 1 +} finally { + Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue +} diff --git a/splash.html b/splash.html new file mode 100644 index 0000000..367dfce --- /dev/null +++ b/splash.html @@ -0,0 +1,453 @@ + + +
+ + +