diff --git a/backend/helpers/auditLog.js b/backend/helpers/auditLog.js index e951228..1f75bef 100644 --- a/backend/helpers/auditLog.js +++ b/backend/helpers/auditLog.js @@ -1,21 +1,19 @@ // Audit Log Helper // Fire-and-forget insert - never blocks the response +const pool = require('../db'); -function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) { +function logAudit({ userId, username, action, entityType, entityId, details, ipAddress }) { const detailsStr = details && typeof details === 'object' ? JSON.stringify(details) : details || null; - db.run( + pool.query( `INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null], - (err) => { - if (err) { - console.error('Audit log error:', err.message); - } - } - ); + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null] + ).catch((err) => { + console.error('Audit log error:', err.message); + }); } module.exports = logAudit; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 8b92cd4..9d9f3eb 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,7 +1,8 @@ // Authentication Middleware +const pool = require('../db'); -// Require authenticated user -function requireAuth(db) { +// Require authenticated user — no parameters needed, pool is imported directly +function requireAuth() { return async (req, res, next) => { const sessionId = req.cookies?.session_id; @@ -10,19 +11,15 @@ function requireAuth(db) { } try { - const session = await new Promise((resolve, reject) => { - db.get( - `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.session_id = ? AND s.expires_at > datetime('now')`, - [sessionId], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.session_id = $1 AND s.expires_at > NOW()`, + [sessionId] + ); + + const session = rows[0]; if (!session) { return res.status(401).json({ error: 'Session expired or invalid' }); diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js index 8474a8e..8c9ebf3 100644 --- a/backend/routes/archerTickets.js +++ b/backend/routes/archerTickets.js @@ -1,5 +1,6 @@ // routes/archerTickets.js const express = require('express'); +const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); @@ -13,42 +14,43 @@ function isValidVendor(vendor) { return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; } -function createArcherTicketsRouter(db) { +function createArcherTicketsRouter() { const router = express.Router(); // Get all Archer tickets (with optional filters) - router.get('/', requireAuth(db), (req, res) => { + router.get('/', requireAuth(), async (req, res) => { const { cve_id, vendor, status } = req.query; let query = 'SELECT * FROM archer_tickets WHERE 1=1'; const params = []; + let paramIndex = 1; if (cve_id) { - query += ' AND cve_id = ?'; + query += ` AND cve_id = $${paramIndex++}`; params.push(cve_id); } if (vendor) { - query += ' AND vendor = ?'; + query += ` AND vendor = $${paramIndex++}`; params.push(vendor); } if (status) { - query += ' AND status = ?'; + query += ` AND status = $${paramIndex++}`; params.push(status); } query += ' ORDER BY created_at DESC'; - db.all(query, params, (err, rows) => { - if (err) { - console.error('Error fetching Archer tickets:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query(query, params); res.json(rows); - }); + } catch (err) { + console.error('Error fetching Archer tickets:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Create Archer ticket - router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { exc_number, archer_url, status, cve_id, vendor } = req.body; // Validation @@ -73,38 +75,38 @@ function createArcherTicketsRouter(db) { const validatedStatus = status || 'Draft'; - db.run( - `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id], - function(err) { - if (err) { - console.error('Error creating Archer ticket:', err); - if (err.message.includes('UNIQUE constraint failed')) { - return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); - } - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query( + `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id] + ); - logAudit(db, { - userId: req.user.id, - action: 'CREATE_ARCHER_TICKET', - entityType: 'archer_ticket', - entityId: String(this.lastID), - details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, - ipAddress: req.ip - }); + logAudit({ + userId: req.user.id, + action: 'CREATE_ARCHER_TICKET', + entityType: 'archer_ticket', + entityId: String(rows[0].id), + details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, + ipAddress: req.ip + }); - res.status(201).json({ - id: this.lastID, - message: 'Archer ticket created successfully' - }); + res.status(201).json({ + id: rows[0].id, + message: 'Archer ticket created successfully' + }); + } catch (err) { + console.error('Error creating Archer ticket:', err); + if (err.code === '23505') { + return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); } - ); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Update Archer ticket - router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { exc_number, archer_url, status } = req.body; @@ -124,29 +126,27 @@ function createArcherTicketsRouter(db) { return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' }); } - // Get existing ticket - db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]); + const existing = rows[0]; if (!existing) { return res.status(404).json({ error: 'Archer ticket not found.' }); } const updates = []; const params = []; + let paramIndex = 1; if (exc_number !== undefined) { - updates.push('exc_number = ?'); + updates.push(`exc_number = $${paramIndex++}`); params.push(exc_number.trim()); } if (archer_url !== undefined) { - updates.push('archer_url = ?'); + updates.push(`archer_url = $${paramIndex++}`); params.push(archer_url || null); } if (status !== undefined) { - updates.push('status = ?'); + updates.push(`status = $${paramIndex++}`); params.push(status); } @@ -154,73 +154,47 @@ function createArcherTicketsRouter(db) { return res.status(400).json({ error: 'No fields to update.' }); } - updates.push('updated_at = CURRENT_TIMESTAMP'); + updates.push('updated_at = NOW()'); params.push(id); - db.run( - `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`, - params, - function(err) { - if (err) { - console.error(err); - if (err.message.includes('UNIQUE constraint failed')) { - return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); - } - return res.status(500).json({ error: 'Internal server error.' }); - } - - logAudit(db, { - userId: req.user.id, - action: 'UPDATE_ARCHER_TICKET', - entityType: 'archer_ticket', - entityId: String(id), - details: { before: existing, changes: req.body }, - ipAddress: req.ip - }); - - res.json({ message: 'Archer ticket updated successfully', changes: this.changes }); - } + const result = await pool.query( + `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`, + params ); - }); - }); - // Helper: perform the actual Archer ticket deletion - function performArcherDelete(db, req, res, id, ticket) { - db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } - - logAudit(db, { + logAudit({ userId: req.user.id, - action: 'DELETE_ARCHER_TICKET', + action: 'UPDATE_ARCHER_TICKET', entityType: 'archer_ticket', entityId: String(id), - details: { deleted: ticket }, + details: { before: existing, changes: req.body }, ipAddress: req.ip }); - res.json({ message: 'Archer ticket deleted successfully' }); - }); - } + res.json({ message: 'Archer ticket updated successfully', changes: result.rowCount }); + } catch (err) { + console.error(err); + if (err.code === '23505') { + return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' }); + } + res.status(500).json({ error: 'Internal server error.' }); + } + }); // Delete Archer ticket - router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]); + const ticket = rows[0]; if (!ticket) { return res.status(404).json({ error: 'Archer ticket not found.' }); } // Admin bypasses all delete restrictions if (req.user.group === 'Admin') { - return performArcherDelete(db, req, res, id, ticket); + return performArcherDelete(); } // Standard_User: ownership check @@ -230,53 +204,63 @@ function createArcherTicketsRouter(db) { // Standard_User: compliance linkage check const excNumber = ticket.exc_number; - db.all( - `SELECT ci.id, ci.extra_json - FROM compliance_items ci - JOIN compliance_uploads cu ON ci.upload_id = cu.id - WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, - [`%${excNumber}%`], - (compErr, compLinks) => { - // If compliance_items table doesn't exist yet, treat as no linkage - if (compErr && compErr.message && compErr.message.includes('no such table')) { - compLinks = []; - } else if (compErr) { - console.error(compErr); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows: compLinks } = await pool.query( + `SELECT ci.id, ci.extra_json + FROM compliance_items ci + JOIN compliance_uploads cu ON ci.upload_id = cu.id + WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`, + [`%${excNumber}%`] + ); - const isLinked = (compLinks || []).some(cl => { - const json = cl.extra_json || ''; - return json.includes(excNumber); - }); + const isLinked = (compLinks || []).some(cl => { + const json = cl.extra_json || ''; + return json.includes(excNumber); + }); - if (isLinked) { - return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); - } - - return performArcherDelete(db, req, res, id, ticket); + if (isLinked) { + return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); } - ); - }); + } catch (compErr) { + if (!compErr.message.includes('does not exist')) throw compErr; + } + + return performArcherDelete(); + + async function performArcherDelete() { + await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]); + + logAudit({ + userId: req.user.id, + action: 'DELETE_ARCHER_TICKET', + entityType: 'archer_ticket', + entityId: String(id), + details: { deleted: ticket }, + ipAddress: req.ip + }); + + res.json({ message: 'Archer ticket deleted successfully' }); + } + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // GET /status-trend — ticket counts grouped by creation date + status - // Used for time-based Archer pipeline chart on the Compliance page. - router.get('/status-trend', requireAuth(db), (req, res) => { - db.all( - `SELECT DATE(created_at) AS date, status, COUNT(*) AS count - FROM archer_tickets - GROUP BY DATE(created_at), status - ORDER BY date ASC`, - [], - (err, rows) => { - if (err) { - console.error('Error fetching Archer status trend:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - res.json({ statusTrend: rows }); - } - ); + router.get('/status-trend', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT DATE(created_at) AS date, status, COUNT(*) AS count + FROM archer_tickets + GROUP BY DATE(created_at), status + ORDER BY date ASC` + ); + res.json({ statusTrend: rows }); + } catch (err) { + console.error('Error fetching Archer status trend:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); return router; diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index d00cf51..6a8888a 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -1,9 +1,10 @@ // Atlas InfoSec Action Plans Routes -// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache +// Proxies CRUD operations to the Atlas API and maintains a local cache // for fast badge rendering on the ReportingPage. const express = require('express'); -const { requireGroup } = require('../middleware/auth'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); @@ -13,34 +14,13 @@ const path = require('path'); const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion']; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -// Diagnostic log helper — writes to atlas-sync-debug.log in the backend folder +// Diagnostic log helper function syncLog(msg) { const line = `${new Date().toISOString()} ${msg}\n`; try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ } console.log(msg); } -// --------------------------------------------------------------------------- -// DB helpers — promise wrappers for callback-based SQLite API -// --------------------------------------------------------------------------- -function dbRun(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); }); - }); -} - -function dbGet(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); - }); -} - -function dbAll(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); - }); -} - // --------------------------------------------------------------------------- // Pure aggregation function — exported for testability // --------------------------------------------------------------------------- @@ -55,7 +35,7 @@ function aggregateAtlasMetrics(rows) { }; for (const row of rows) { - if (row.has_action_plan === 1) { + if (row.has_action_plan === true || row.has_action_plan === 1) { result.hostsWithPlans++; } else { result.hostsWithoutPlans++; @@ -65,7 +45,6 @@ function aggregateAtlasMetrics(rows) { try { plans = JSON.parse(row.plans_json); } catch (e) { - // Invalid JSON — skip plan details for this row continue; } @@ -73,11 +52,9 @@ function aggregateAtlasMetrics(rows) { for (const plan of plans) { result.totalPlans++; - if (plan.plan_type) { result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1; } - if (plan.status) { result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1; } @@ -90,28 +67,17 @@ function aggregateAtlasMetrics(rows) { // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- -function createAtlasRouter(db, requireAuth) { +function createAtlasRouter() { const router = express.Router(); - // ----------------------------------------------------------------------- // GET /metrics - // Return aggregated Atlas metrics for chart rendering. - // Auth: any authenticated user - // - // Response 200: - // { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number, - // plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number }, - // totalPlans: number } - // Response 503: { error: string } — Atlas not configured - // Response 500: { error: string } — DB query failure - // ----------------------------------------------------------------------- - router.get('/metrics', requireAuth(db), async (req, res) => { + router.get('/metrics', requireAuth(), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - const rows = await dbAll(db, + const { rows } = await pool.query( `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` ); const metrics = aggregateAtlasMetrics(rows); @@ -122,23 +88,14 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // GET /status - // Return all cached Atlas rows for badge rendering. - // Auth: any authenticated user - // - // Response 200: - // [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ] - // Response 503: { error: string } — Atlas not configured - // Response 500: { error: string } — DB query failure - // ----------------------------------------------------------------------- - router.get('/status', requireAuth(db), async (req, res) => { + router.get('/status', requireAuth(), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - const rows = await dbAll(db, + const { rows } = await pool.query( `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` ); res.json(rows); @@ -148,49 +105,23 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // POST /sync - // Sync Atlas action plan data for all hosts found in the Ivanti cache. - // Auth: Admin or Standard_User - // - // Request body: none - // Response 200: - // { synced: number, withPlans: number, failed: number } - // Response 503: { error: string } — Atlas not configured - // Response 500: { error: string } — sync failure or Ivanti cache parse error - // ----------------------------------------------------------------------- - router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } try { - // 1. Read Ivanti findings cache and extract unique non-null hostIds - const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`); - if (!cacheRow || !cacheRow.findings_json) { - return res.json({ synced: 0, withPlans: 0, failed: 0 }); - } - - let findings; - try { - findings = JSON.parse(cacheRow.findings_json); - } catch (parseErr) { - return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' }); - } - - const hostIdSet = new Set(); - for (const f of findings) { - if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) { - hostIdSet.add(f.hostId); - } - } - const hostIds = [...hostIdSet]; + // Read Ivanti findings and extract unique non-null hostIds + const { rows: findingsRows } = await pool.query( + `SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0` + ); + const hostIds = findingsRows.map(r => r.host_id); if (hostIds.length === 0) { return res.json({ synced: 0, withPlans: 0, failed: 0 }); } - // 2. Process hosts in batches of 5 concurrent requests let synced = 0; let withPlans = 0; let failed = 0; @@ -219,7 +150,6 @@ function createAtlasRouter(db, requireAuth) { let activePlans = []; try { const parsed = JSON.parse(result.body); - // Atlas returns { active: [...], inactive: [...] } if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { activePlans = Array.isArray(parsed.active) ? parsed.active : []; const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : []; @@ -233,30 +163,24 @@ function createAtlasRouter(db, requireAuth) { activePlans = []; } - // Badge counts only active plans — inactive are historical const planCount = activePlans.length; - const hasActionPlan = planCount > 0 ? 1 : 0; - - console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`); + const hasActionPlan = planCount > 0; try { - // If Atlas returns 0 plans but we have a recent optimistic - // entry (from bulk creation within the last 10 minutes), - // keep the optimistic value — Atlas's GET may lag behind. - if (hasActionPlan === 0) { - const existing = await dbGet(db, - `SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`, + if (!hasActionPlan) { + const { rows: existingRows } = await pool.query( + `SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`, [hostId] ); - if (existing && existing.has_action_plan === 1) { + const existing = existingRows[0]; + if (existing && existing.has_action_plan === true) { let existingPlans = []; try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {} const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create'); if (hasBulkStub) { - const ageMs = Date.now() - new Date(existing.synced_at + 'Z').getTime(); + const ageMs = Date.now() - new Date(existing.synced_at).getTime(); const TEN_MINUTES = 10 * 60 * 1000; if (ageMs < TEN_MINUTES) { - console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`); synced++; withPlans++; continue; @@ -265,14 +189,14 @@ function createAtlasRouter(db, requireAuth) { } } - await dbRun(db, + await pool.query( `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) - VALUES (?, ?, ?, ?, datetime('now')) + VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT(host_id) DO UPDATE SET - has_action_plan = excluded.has_action_plan, - plan_count = excluded.plan_count, - plans_json = excluded.plans_json, - synced_at = excluded.synced_at`, + has_action_plan = EXCLUDED.has_action_plan, + plan_count = EXCLUDED.plan_count, + plans_json = EXCLUDED.plans_json, + synced_at = EXCLUDED.synced_at`, [hostId, hasActionPlan, planCount, JSON.stringify(allPlans)] ); } catch (dbErr) { @@ -283,13 +207,12 @@ function createAtlasRouter(db, requireAuth) { if (hasActionPlan) withPlans++; } else { failed++; - console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}, body=${result.body}`); + console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`); } } } - // 3. Log audit entry - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ATLAS_SYNC', @@ -306,18 +229,8 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // GET /hosts/:hostId/action-plans - // Proxy to Atlas API — returns live action plan data for a single host. - // Auth: any authenticated user - // - // Params: hostId (positive integer) - // Response 2xx: proxied Atlas response body (parsed JSON or raw) - // Response 400: { error: string } — invalid hostId - // Response 503: { error: string } — Atlas not configured - // Response 502: { error: string } — Atlas API unreachable - // ----------------------------------------------------------------------- - router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => { + router.get('/hosts/:hostId/action-plans', requireAuth(), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } @@ -329,23 +242,13 @@ function createAtlasRouter(db, requireAuth) { try { const result = await atlasGet('/hosts/' + hostId + '/action-plans'); - if (result.status >= 200 && result.status < 300) { let body; - try { - body = JSON.parse(result.body); - } catch (e) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (e) { body = result.body; } res.status(result.status).json(body); } else { - // Forward non-2xx Atlas responses to the client let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (e) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; } res.status(result.status).json(errorBody); } } catch (err) { @@ -354,22 +257,8 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // PUT /hosts/:hostId/action-plans - // Create a new action plan for a host. - // Auth: Admin or Standard_User - // - // Params: hostId (positive integer) - // Request body: - // { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD), - // qualys_id?: string, active_host_findings_id?: string, - // jira_vnr?: string, archer_exc?: string } - // Response 2xx: proxied Atlas response body - // Response 400: { error: string } — invalid hostId, plan_type, or commit_date - // Response 503: { error: string } — Atlas not configured - // Response 502: { error: string } — Atlas API unreachable - // ----------------------------------------------------------------------- - router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.put('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } @@ -380,11 +269,9 @@ function createAtlasRouter(db, requireAuth) { } const { plan_type, commit_date } = req.body || {}; - if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) { return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') }); } - if (!commit_date || !DATE_PATTERN.test(commit_date)) { return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' }); } @@ -392,7 +279,7 @@ function createAtlasRouter(db, requireAuth) { try { const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body); - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ATLAS_CREATE_PLAN', @@ -404,19 +291,11 @@ function createAtlasRouter(db, requireAuth) { if (result.status >= 200 && result.status < 300) { let body; - try { - body = JSON.parse(result.body); - } catch (e) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (e) { body = result.body; } res.status(result.status).json(body); } else { let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (e) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; } res.status(result.status).json(errorBody); } } catch (err) { @@ -425,20 +304,8 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // PATCH /hosts/:hostId/action-plans - // Update an existing action plan for a host. - // Auth: Admin or Standard_User - // - // Params: hostId (positive integer) - // Request body: - // { action_plan_id: string (non-empty), updates: object (non-null, non-array) } - // Response 2xx: proxied Atlas response body - // Response 400: { error: string } — invalid hostId, action_plan_id, or updates - // Response 503: { error: string } — Atlas not configured - // Response 502: { error: string } — Atlas API unreachable - // ----------------------------------------------------------------------- - router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.patch('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } @@ -449,11 +316,9 @@ function createAtlasRouter(db, requireAuth) { } const { action_plan_id, updates } = req.body || {}; - if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') { return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' }); } - if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { return res.status(400).json({ error: 'updates is required and must be an object' }); } @@ -461,7 +326,7 @@ function createAtlasRouter(db, requireAuth) { try { const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body); - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ATLAS_UPDATE_PLAN', @@ -473,19 +338,11 @@ function createAtlasRouter(db, requireAuth) { if (result.status >= 200 && result.status < 300) { let body; - try { - body = JSON.parse(result.body); - } catch (e) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (e) { body = result.body; } res.status(result.status).json(body); } else { let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (e) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; } res.status(result.status).json(errorBody); } } catch (err) { @@ -494,41 +351,24 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // POST /hosts/bulk-action-plans - // Create action plans for multiple hosts at once. - // Auth: Admin or Standard_User - // - // Request body: - // { host_ids: number[] (non-empty, positive integers), - // plan_type: string (one of VALID_PLAN_TYPES), - // commit_date: string (YYYY-MM-DD) } - // Response 2xx: proxied Atlas response body - // Response 400: { error: string } — invalid host_ids, plan_type, or commit_date - // Response 503: { error: string } — Atlas not configured - // Response 502: { error: string } — Atlas API unreachable - // ----------------------------------------------------------------------- - router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/hosts/bulk-action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } const { host_ids, plan_type, commit_date } = req.body || {}; - if (!Array.isArray(host_ids) || host_ids.length === 0) { return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); } - for (const id of host_ids) { if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); } } - if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) { return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') }); } - if (!commit_date || !DATE_PATTERN.test(commit_date)) { return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' }); } @@ -538,40 +378,34 @@ function createAtlasRouter(db, requireAuth) { if (result.status >= 200 && result.status < 300) { let body; - try { - body = JSON.parse(result.body); - } catch (e) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (e) { body = result.body; } - // Optimistically update local cache for all submitted hosts. - // Atlas's individual GET endpoint may lag behind the bulk - // creation, so we mark every host as having a plan now rather - // than waiting for the next sync to discover it. + // Optimistically update local cache for (const hid of host_ids) { try { - const existing = await dbGet(db, - `SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`, + const { rows: existingRows } = await pool.query( + `SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`, [hid] ); + const existing = existingRows[0]; let existingPlans = []; if (existing && existing.plans_json) { - try { existingPlans = JSON.parse(existing.plans_json); } catch (_) { /* ignore */ } + try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {} } const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() }; const updatedPlans = [...existingPlans, stubPlan]; const newCount = updatedPlans.length; - await dbRun(db, + await pool.query( `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) - VALUES (?, 1, ?, ?, datetime('now')) + VALUES ($1, true, $2, $3, NOW()) ON CONFLICT(host_id) DO UPDATE SET - has_action_plan = 1, - plan_count = excluded.plan_count, - plans_json = excluded.plans_json, - synced_at = excluded.synced_at`, + has_action_plan = true, + plan_count = EXCLUDED.plan_count, + plans_json = EXCLUDED.plans_json, + synced_at = EXCLUDED.synced_at`, [hid, newCount, JSON.stringify(updatedPlans)] ); } catch (cacheErr) { @@ -579,7 +413,7 @@ function createAtlasRouter(db, requireAuth) { } } - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ATLAS_BULK_CREATE_PLANS', @@ -592,11 +426,7 @@ function createAtlasRouter(db, requireAuth) { res.status(result.status).json(body); } else { let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (e) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; } res.status(result.status).json(errorBody); } } catch (err) { @@ -605,29 +435,16 @@ function createAtlasRouter(db, requireAuth) { } }); - // ----------------------------------------------------------------------- // POST /hosts/vulnerabilities - // Fetch active Ivanti vulnerabilities for multiple hosts from Atlas. - // Used by the bulk action plan modal to populate the qualys_id dropdown. - // Auth: any authenticated user - // - // Request body: { host_ids: number[] } - // Response 2xx: proxied Atlas response body - // Response 400: { error: string } — invalid host_ids - // Response 503: { error: string } — Atlas not configured - // Response 502: { error: string } — Atlas API unreachable - // ----------------------------------------------------------------------- - router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => { + router.post('/hosts/vulnerabilities', requireAuth(), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); } const { host_ids } = req.body || {}; - if (!Array.isArray(host_ids) || host_ids.length === 0) { return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); } - for (const id of host_ids) { if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); @@ -637,24 +454,13 @@ function createAtlasRouter(db, requireAuth) { try { const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 }); - console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length); - console.log('[Atlas] Response preview:', result.body?.substring(0, 500)); - if (result.status >= 200 && result.status < 300) { let body; - try { - body = JSON.parse(result.body); - } catch (e) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (e) { body = result.body; } res.status(result.status).json(body); } else { let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (e) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; } res.status(result.status).json(errorBody); } } catch (err) { diff --git a/backend/routes/auditLog.js b/backend/routes/auditLog.js index 62ee278..d53a119 100644 --- a/backend/routes/auditLog.js +++ b/backend/routes/auditLog.js @@ -1,11 +1,13 @@ // Audit Log Routes (Admin only) const express = require('express'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); -function createAuditLogRouter(db, requireAuth, requireGroup) { +function createAuditLogRouter() { const router = express.Router(); // All routes require Admin group - router.use(requireAuth(db), requireGroup('Admin')); + router.use(requireAuth(), requireGroup('Admin')); // Get paginated audit logs with filters router.get('/', async (req, res) => { @@ -24,25 +26,26 @@ function createAuditLogRouter(db, requireAuth, requireGroup) { let where = []; let params = []; + let paramIndex = 1; if (user) { - where.push('username LIKE ?'); + where.push(`username ILIKE $${paramIndex++}`); params.push(`%${user}%`); } if (action) { - where.push('action = ?'); + where.push(`action = $${paramIndex++}`); params.push(action); } if (entityType) { - where.push('entity_type = ?'); + where.push(`entity_type = $${paramIndex++}`); params.push(entityType); } if (startDate) { - where.push('created_at >= ?'); + where.push(`created_at >= $${paramIndex++}`); params.push(startDate); } if (endDate) { - where.push('created_at <= ?'); + where.push(`created_at <= $${paramIndex++}`); params.push(endDate + ' 23:59:59'); } @@ -50,36 +53,25 @@ function createAuditLogRouter(db, requireAuth, requireGroup) { try { // Get total count - const countRow = await new Promise((resolve, reject) => { - db.get( - `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`, - params, - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const countResult = await pool.query( + `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0].total); // Get paginated results - const rows = await new Promise((resolve, reject) => { - db.all( - `SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, - [...params, pageSize, offset], - (err, rows) => { - if (err) reject(err); - else resolve(rows); - } - ); - }); + const dataResult = await pool.query( + `SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, pageSize, offset] + ); res.json({ - logs: rows, + logs: dataResult.rows, pagination: { page: parseInt(page), limit: pageSize, - total: countRow.total, - totalPages: Math.ceil(countRow.total / pageSize) + total: total, + totalPages: Math.ceil(total / pageSize) } }); } catch (err) { @@ -91,16 +83,9 @@ function createAuditLogRouter(db, requireAuth, requireGroup) { // Get distinct action types for filter dropdown router.get('/actions', async (req, res) => { try { - const rows = await new Promise((resolve, reject) => { - db.all( - 'SELECT DISTINCT action FROM audit_logs ORDER BY action', - (err, rows) => { - if (err) reject(err); - else resolve(rows); - } - ); - }); - + const { rows } = await pool.query( + 'SELECT DISTINCT action FROM audit_logs ORDER BY action' + ); res.json(rows.map(r => r.action)); } catch (err) { console.error('Audit log actions error:', err); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 64704f7..e243150 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,6 +3,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); +const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const loginLimiter = rateLimit({ @@ -13,7 +14,7 @@ const loginLimiter = rateLimit({ message: { error: 'Too many login attempts. Please try again in 15 minutes.' } }); -function createAuthRouter(db, logAudit) { +function createAuthRouter(logAudit) { const router = express.Router(); /** @@ -39,19 +40,14 @@ function createAuthRouter(db, logAudit) { try { // Find user - const user = await new Promise((resolve, reject) => { - db.get( - 'SELECT * FROM users WHERE username = ?', - [username], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + 'SELECT * FROM users WHERE username = $1', + [username] + ); + const user = rows[0]; if (!user) { - logAudit(db, { + logAudit({ userId: null, username: username, action: 'login_failed', @@ -64,7 +60,7 @@ function createAuthRouter(db, logAudit) { } if (!user.is_active) { - logAudit(db, { + logAudit({ userId: user.id, username: username, action: 'login_failed', @@ -79,7 +75,7 @@ function createAuthRouter(db, logAudit) { // Verify password const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) { - logAudit(db, { + logAudit({ userId: user.id, username: username, action: 'login_failed', @@ -96,28 +92,16 @@ function createAuthRouter(db, logAudit) { const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours // Create session - await new Promise((resolve, reject) => { - db.run( - 'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)', - [sessionId, user.id, expiresAt.toISOString()], - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await pool.query( + 'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)', + [sessionId, user.id, expiresAt.toISOString()] + ); // Update last login - await new Promise((resolve, reject) => { - db.run( - 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', - [user.id], - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await pool.query( + 'UPDATE users SET last_login = NOW() WHERE id = $1', + [user.id] + ); // Set cookie res.cookie('session_id', sessionId, { @@ -127,7 +111,7 @@ function createAuthRouter(db, logAudit) { maxAge: 24 * 60 * 60 * 1000 // 24 hours }); - logAudit(db, { + logAudit({ userId: user.id, username: user.username, action: 'login', @@ -166,27 +150,31 @@ function createAuthRouter(db, logAudit) { if (sessionId) { // Look up user before deleting session - const session = await new Promise((resolve) => { - db.get( + let session = null; + try { + const { rows } = await pool.query( `SELECT u.id as user_id, u.username FROM sessions s JOIN users u ON s.user_id = u.id - WHERE s.session_id = ?`, - [sessionId], - (err, row) => resolve(row || null) + WHERE s.session_id = $1`, + [sessionId] ); - }); + session = rows[0] || null; + } catch (err) { + // Non-critical — proceed with logout + } // Delete session from database - await new Promise((resolve) => { - db.run( - 'DELETE FROM sessions WHERE session_id = ?', - [sessionId], - () => resolve() + try { + await pool.query( + 'DELETE FROM sessions WHERE session_id = $1', + [sessionId] ); - }); + } catch (err) { + // Non-critical — proceed with logout + } if (session) { - logAudit(db, { + logAudit({ userId: session.user_id, username: session.username, action: 'logout', @@ -221,19 +209,15 @@ function createAuthRouter(db, logAudit) { } try { - const session = await new Promise((resolve, reject) => { - db.get( - `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.session_id = ? AND s.expires_at > datetime('now')`, - [sessionId], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.session_id = $1 AND s.expires_at > NOW()`, + [sessionId] + ); + + const session = rows[0]; if (!session) { res.clearCookie('session_id'); @@ -271,18 +255,14 @@ function createAuthRouter(db, logAudit) { * @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie) * @returns {object} 500 - { error: 'Failed to fetch profile' } */ - router.get('/profile', requireAuth(db), async (req, res) => { + router.get('/profile', requireAuth(), async (req, res) => { try { - const user = await new Promise((resolve, reject) => { - db.get( - 'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?', - [req.user.id], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + 'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1', + [req.user.id] + ); + + const user = rows[0]; if (!user || !user.is_active) { res.clearCookie('session_id'); @@ -327,7 +307,7 @@ function createAuthRouter(db, logAudit) { * @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' } * @returns {object} 500 - { error: 'Failed to change password' } */ - router.post('/change-password', requireAuth(db), passwordChangeLimiter, async (req, res) => { + router.post('/change-password', requireAuth(), passwordChangeLimiter, async (req, res) => { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) { @@ -340,16 +320,12 @@ function createAuthRouter(db, logAudit) { try { // Fetch user's password hash and active status - const user = await new Promise((resolve, reject) => { - db.get( - 'SELECT password_hash, is_active FROM users WHERE id = ?', - [req.user.id], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + 'SELECT password_hash, is_active FROM users WHERE id = $1', + [req.user.id] + ); + + const user = rows[0]; if (!user || !user.is_active) { return res.status(401).json({ error: 'Account is disabled' }); @@ -363,18 +339,12 @@ function createAuthRouter(db, logAudit) { // Hash new password and update const newHash = await bcrypt.hash(newPassword, 10); - await new Promise((resolve, reject) => { - db.run( - 'UPDATE users SET password_hash = ? WHERE id = ?', - [newHash, req.user.id], - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await pool.query( + 'UPDATE users SET password_hash = $1 WHERE id = $2', + [newHash, req.user.id] + ); - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'password_change', @@ -401,17 +371,9 @@ function createAuthRouter(db, logAudit) { * @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' } * @returns {object} 500 - { error: 'Cleanup failed' } */ - router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => { + router.post('/cleanup-sessions', requireAuth(), requireGroup('Admin'), async (req, res) => { try { - await new Promise((resolve, reject) => { - db.run( - "DELETE FROM sessions WHERE expires_at < datetime('now')", - (err) => { - if (err) reject(err); - else resolve(); - } - ); - }); + await pool.query("DELETE FROM sessions WHERE expires_at < NOW()"); res.json({ message: 'Expired sessions cleaned up' }); } catch (err) { console.error('Session cleanup error:', err); diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index fc7db38..196e455 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -3,7 +3,8 @@ // the two-step update_token flow for mutations. const express = require('express'); -const { requireGroup } = require('../middleware/auth'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const { isConfigured, @@ -16,21 +17,6 @@ const { redirectAsset, } = require('../helpers/cardApi'); -// --------------------------------------------------------------------------- -// DB helpers — promise wrappers for callback-based SQLite API -// --------------------------------------------------------------------------- -function dbRun(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); }); - }); -} - -function dbGet(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); - }); -} - // --------------------------------------------------------------------------- // Error classification — maps CARD API / token errors to client responses // --------------------------------------------------------------------------- @@ -38,7 +24,6 @@ function handleCardError(err, res) { const msg = err.message || String(err); console.error('[card-api]', msg); - // Token endpoint errors (from acquireToken rejections) if (msg.includes('Token acquisition failed')) { if (msg.includes('HTTP 401')) { return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' }); @@ -51,7 +36,6 @@ function handleCardError(err, res) { } } - // API call errors (after automatic 401 retry in helper) if (msg.includes('401')) { return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' }); } @@ -59,73 +43,47 @@ function handleCardError(err, res) { return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' }); } - // Catch-all return res.status(502).json({ error: 'CARD API request failed.', details: msg }); } // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- -function createCardApiRouter(db, requireAuth) { +function createCardApiRouter() { const router = express.Router(); - // ------------------------------------------------------------------- // GET /status - // Returns whether the CARD API integration is configured. - // ------------------------------------------------------------------- - router.get('/status', requireAuth(db), (req, res) => { + router.get('/status', requireAuth(), (req, res) => { if (!isConfigured) { - return res.status(503).json({ - configured: false, - error: 'CARD API is not configured.', - missingVars, - }); + return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars }); } res.json({ configured: true }); }); - // ------------------------------------------------------------------- // GET /teams - // Proxy CARD teams list. - // ------------------------------------------------------------------- - router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } try { const result = await getTeams(); - if (result.ok) { let body; - try { - body = JSON.parse(result.body); - } catch (_) { - body = result.body; - } - // CARD API wraps teams in { teams: [...], response_time: ... } + try { body = JSON.parse(result.body); } catch (_) { body = result.body; } const teams = Array.isArray(body) ? body : (body && body.teams) || []; return res.json(teams); } - - // Forward CARD error status let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (_) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); - // ------------------------------------------------------------------- // GET /teams/:teamName/assets - // Proxy team assets with required disposition filter. - // ------------------------------------------------------------------- - router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -146,20 +104,15 @@ function createCardApiRouter(db, requireAuth) { if (result.ok) { let body; - try { - body = JSON.parse(result.body); - } catch (_) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (_) { body = result.body; } - // Audit log for asset search (fire-and-forget) let resultCount = 0; if (body && typeof body === 'object' && typeof body.total === 'number') { resultCount = body.total; } else if (body && Array.isArray(body.assets)) { resultCount = body.assets.length; } - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_search', @@ -173,22 +126,15 @@ function createCardApiRouter(db, requireAuth) { } let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (_) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); - // ------------------------------------------------------------------- // GET /owner/:assetId - // Proxy owner record lookup. - // ------------------------------------------------------------------- - router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -197,34 +143,21 @@ function createCardApiRouter(db, requireAuth) { try { const result = await getOwner(assetId); - if (result.ok) { let body; - try { - body = JSON.parse(result.body); - } catch (_) { - body = result.body; - } + try { body = JSON.parse(result.body); } catch (_) { body = result.body; } return res.json(body); } - let errorBody; - try { - errorBody = JSON.parse(result.body); - } catch (_) { - errorBody = { error: result.body }; - } + try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; } return res.status(result.status).json(errorBody); } catch (err) { return handleCardError(err, res); } }); - // ------------------------------------------------------------------- // POST /queue/:queueItemId/confirm - // Confirm asset to a team via CARD API. - // ------------------------------------------------------------------- - router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -232,7 +165,6 @@ function createCardApiRouter(db, requireAuth) { const { queueItemId } = req.params; const { teamName, assetId, comment } = req.body; - // Validate required fields if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } @@ -241,11 +173,11 @@ function createCardApiRouter(db, requireAuth) { } try { - // Validate queue item - const item = await dbGet(db, - 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', + const { rows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); + const item = rows[0]; if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); @@ -254,20 +186,10 @@ function createCardApiRouter(db, requireAuth) { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } - // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); @@ -279,82 +201,39 @@ function createCardApiRouter(db, requireAuth) { if (!updateToken) { const errMsg = 'update_token not found in owner record.'; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } - // Step 2: Execute confirm mutation const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); if (confirmResult.ok) { - // Update queue item to complete - await dbRun(db, - "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + await pool.query( + "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } - // Audit log (fire-and-forget) - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_confirm', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } - // Mutation failed — leave queue item as pending const errMsg = `Confirm failed: HTTP ${confirmResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, - ipAddress: req.ip, - }); - + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } return res.status(confirmResult.status).json(errorBody); } catch (err) { - console.error('[card-api] Confirm error:', err.message); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip }); return handleCardError(err, res); } }); - // ------------------------------------------------------------------- // POST /queue/:queueItemId/decline - // Decline asset from a team via CARD API. - // ------------------------------------------------------------------- - router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -362,7 +241,6 @@ function createCardApiRouter(db, requireAuth) { const { queueItemId } = req.params; const { teamName, assetId, comment } = req.body; - // Validate required fields if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { return res.status(400).json({ error: 'teamName is required.' }); } @@ -371,11 +249,11 @@ function createCardApiRouter(db, requireAuth) { } try { - // Validate queue item - const item = await dbGet(db, - 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', + const { rows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); + const item = rows[0]; if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); @@ -384,20 +262,10 @@ function createCardApiRouter(db, requireAuth) { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } - // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); @@ -409,80 +277,39 @@ function createCardApiRouter(db, requireAuth) { if (!updateToken) { const errMsg = 'update_token not found in owner record.'; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } - // Step 2: Execute decline mutation const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); if (declineResult.ok) { - await dbRun(db, - "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + await pool.query( + "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_decline', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } - // Mutation failed const errMsg = `Decline failed: HTTP ${declineResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, - ipAddress: req.ip, - }); - + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } return res.status(declineResult.status).json(errorBody); } catch (err) { - console.error('[card-api] Decline error:', err.message); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip }); return handleCardError(err, res); } }); - // ------------------------------------------------------------------- // POST /queue/:queueItemId/redirect - // Redirect asset from one team to another via CARD API. - // ------------------------------------------------------------------- - router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!isConfigured) { return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); } @@ -490,7 +317,6 @@ function createCardApiRouter(db, requireAuth) { const { queueItemId } = req.params; const { fromTeam, toTeam, assetId } = req.body; - // Validate required fields if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) { return res.status(400).json({ error: 'fromTeam is required.' }); } @@ -502,11 +328,11 @@ function createCardApiRouter(db, requireAuth) { } try { - // Validate queue item - const item = await dbGet(db, - 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?', + const { rows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3', [queueItemId, req.user.id, 'CARD'] ); + const item = rows[0]; if (!item) { return res.status(404).json({ error: 'Queue item not found.' }); @@ -515,20 +341,10 @@ function createCardApiRouter(db, requireAuth) { return res.status(400).json({ error: 'Only pending queue items can be executed.' }); } - // Step 1: Get owner record for update_token const ownerResult = await getOwner(assetId); if (!ownerResult.ok) { const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } return res.status(ownerResult.status).json(errorBody); @@ -540,71 +356,33 @@ function createCardApiRouter(db, requireAuth) { if (!updateToken) { const errMsg = 'update_token not found in owner record.'; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); } - // Step 2: Execute redirect mutation const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); if (redirectResult.ok) { - await dbRun(db, - "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + await pool.query( + "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1", [queueItemId] ); let cardResponse; try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_redirect', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip }); return res.json({ success: true, cardResponse }); } - // Mutation failed const errMsg = `Redirect failed: HTTP ${redirectResult.status}`; - console.error('[card-api]', errMsg); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, - ipAddress: req.ip, - }); - + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip }); let errorBody; try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } return res.status(redirectResult.status).json(errorBody); } catch (err) { - console.error('[card-api] Redirect error:', err.message); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'card_action_failed', - entityType: 'ivanti_todo_queue', - entityId: String(queueItemId), - details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip }); return handleCardError(err, res); } }); diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 2798c93..fc4a388 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -1,27 +1,13 @@ // Compliance Routes — AEO metric tracking // Handles xlsx upload/parse, non-compliant item history, and notes. -// -// Endpoints: -// POST /preview — parse xlsx, run drift check, compute diff (no DB write) -// POST /reconcile-config — patch compliance_config.json to resolve drift findings -// POST /commit — commit a previewed upload to DB -// GET /uploads — list all uploads -// POST /rollback/:uploadId — roll back the most recent upload (Admin only) -// GET /summary — metric health cards for a team (from latest upload) -// GET /items — non-compliant devices grouped by hostname (?team=X&status=active) -// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device -// POST /notes — add a note to one or more (hostname, metric_id) pairs -// GET /notes/:hostname/:metricId — notes for a specific device+metric -// GET /trends — per-upload totals + per-team counts for time-series charts -// GET /mttr — aging findings distribution by seen_count bucket and team -// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end) -// GET /category-trend — active counts per category per upload for stacked area chart const express = require('express'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { spawn } = require('child_process'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker'); const logAudit = require('../helpers/auditLog'); @@ -32,28 +18,6 @@ const PYTHON_BIN = process.env.PYTHON_BIN || 'python3'; const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp'); const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']); -// --------------------------------------------------------------------------- -// DB helpers -// --------------------------------------------------------------------------- -function dbRun(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.run(sql, params, function (err) { - if (err) reject(err); - else resolve({ lastID: this.lastID, changes: this.changes }); - }); - }); -} -function dbGet(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row || null); }); - }); -} -function dbAll(db, sql, params = []) { - return new Promise((resolve, reject) => { - db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); - }); -} - // --------------------------------------------------------------------------- // Run Python parser, return parsed object // --------------------------------------------------------------------------- @@ -73,9 +37,6 @@ function parseXlsx(filePath) { }); } -// --------------------------------------------------------------------------- -// Run Python schema extractor, return xlsx schema object -// --------------------------------------------------------------------------- function extractXlsxSchema(filePath) { return new Promise((resolve, reject) => { const py = spawn(PYTHON_BIN, [SCHEMA_SCRIPT, filePath]); @@ -92,9 +53,6 @@ function extractXlsxSchema(filePath) { }); } -// --------------------------------------------------------------------------- -// Validate that a temp file path is safely within uploads/temp/ -// --------------------------------------------------------------------------- function isSafeTempPath(filePath) { const resolved = path.resolve(filePath); return resolved.startsWith(TEMP_DIR + path.sep) && path.extname(resolved) === '.json'; @@ -103,8 +61,8 @@ function isSafeTempPath(filePath) { // --------------------------------------------------------------------------- // Compute diff: new / recurring / resolved // --------------------------------------------------------------------------- -async function computeDiff(db, incomingItems) { - const activeRows = await dbAll(db, +async function computeDiff(incomingItems) { + const { rows: activeRows } = await pool.query( `SELECT hostname, metric_id FROM compliance_items WHERE status = 'active'` ); const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`)); @@ -120,9 +78,8 @@ async function computeDiff(db, incomingItems) { // --------------------------------------------------------------------------- // Write a parsed upload to the DB (within a transaction) // --------------------------------------------------------------------------- -async function persistUpload(db, { items, summary, reportDate, filename, userId }) { - // Pull current active items before we modify anything - const activeRows = await dbAll(db, +async function persistUpload({ items, summary, reportDate, filename, userId }) { + const { rows: activeRows } = await pool.query( `SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'` ); const activeMap = {}; @@ -130,14 +87,18 @@ async function persistUpload(db, { items, summary, reportDate, filename, userId const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`)); - await dbRun(db, 'BEGIN TRANSACTION'); + const client = await pool.connect(); try { + await client.query('BEGIN'); + // 1. Insert the upload record - const { lastID: uploadId } = await dbRun(db, + const uploadResult = await client.query( `INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json) - VALUES (?, ?, ?, datetime('now'), ?)`, + VALUES ($1, $2, $3, NOW(), $4) + RETURNING id`, [filename, reportDate || null, userId || null, JSON.stringify(summary)] ); + const uploadId = uploadResult.rows[0].id; let newCount = 0, recurringCount = 0, resolvedCount = 0; @@ -148,21 +109,19 @@ async function persistUpload(db, { items, summary, reportDate, filename, userId const extraStr = JSON.stringify(item.extra_json || {}); if (existing) { - // Recurring — bump seen_count, refresh snapshot fields - await dbRun(db, + await client.query( `UPDATE compliance_items - SET upload_id = ?, seen_count = ?, ip_address = ?, device_type = ?, extra_json = ? - WHERE id = ?`, + SET upload_id = $1, seen_count = $2, ip_address = $3, device_type = $4, extra_json = $5 + WHERE id = $6`, [uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, existing.id] ); recurringCount++; } else { - // New item (or previously resolved and re-appearing) - await dbRun(db, + await client.query( `INSERT INTO compliance_items (upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc, category, extra_json, status, first_seen_upload_id, seen_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1)`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, 1)`, [uploadId, item.hostname, item.ip_address, item.device_type, item.team, item.metric_id, item.metric_desc, item.category, extraStr, uploadId] ); @@ -173,10 +132,8 @@ async function persistUpload(db, { items, summary, reportDate, filename, userId // 3. Mark items not present in this upload as resolved for (const [key, row] of Object.entries(activeMap)) { if (!newKeys.has(key)) { - await dbRun(db, - `UPDATE compliance_items - SET status = 'resolved', resolved_upload_id = ? - WHERE id = ?`, + await client.query( + `UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $1 WHERE id = $2`, [uploadId, row.id] ); resolvedCount++; @@ -184,19 +141,18 @@ async function persistUpload(db, { items, summary, reportDate, filename, userId } // 4. Update upload with final counts - await dbRun(db, - `UPDATE compliance_uploads - SET new_count = ?, resolved_count = ?, recurring_count = ? - WHERE id = ?`, + await client.query( + `UPDATE compliance_uploads SET new_count = $1, resolved_count = $2, recurring_count = $3 WHERE id = $4`, [newCount, resolvedCount, recurringCount, uploadId] ); - await dbRun(db, 'COMMIT'); + await client.query('COMMIT'); return { uploadId, newCount, recurringCount, resolvedCount }; - } catch (err) { - await dbRun(db, 'ROLLBACK').catch(() => {}); + await client.query('ROLLBACK'); throw err; + } finally { + client.release(); } } @@ -205,90 +161,55 @@ async function persistUpload(db, { items, summary, reportDate, filename, userId // --------------------------------------------------------------------------- function groupByHostname(rows, noteHostnames) { const deviceMap = {}; - for (const row of rows) { if (!deviceMap[row.hostname]) { deviceMap[row.hostname] = { - hostname: row.hostname, - ip_address: row.ip_address || '', - device_type: row.device_type || '', - team: row.team || '', - status: row.status, - failing_metrics: [], - seen_count: row.seen_count || 1, - first_seen: row.first_seen || null, - last_seen: row.last_seen || null, - resolved_on: row.resolved_on || null, - has_notes: noteHostnames.has(row.hostname), + hostname: row.hostname, ip_address: row.ip_address || '', device_type: row.device_type || '', + team: row.team || '', status: row.status, failing_metrics: [], + seen_count: row.seen_count || 1, first_seen: row.first_seen || null, + last_seen: row.last_seen || null, resolved_on: row.resolved_on || null, + has_notes: noteHostnames.has(row.hostname), }; } - const dev = deviceMap[row.hostname]; - dev.failing_metrics.push({ - metric_id: row.metric_id, - metric_desc: row.metric_desc || '', - category: row.category || '', - }); - // Use the highest seen_count and earliest first_seen across all metrics + dev.failing_metrics.push({ metric_id: row.metric_id, metric_desc: row.metric_desc || '', category: row.category || '' }); if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count; - if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) - dev.first_seen = row.first_seen; - if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) - dev.last_seen = row.last_seen; + if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen; + if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen; } - return Object.values(deviceMap); } // --------------------------------------------------------------------------- -// Pure function: bucket active items by age group and pivot per-team counts +// Pure helpers // --------------------------------------------------------------------------- const BUCKET_ORDER = ['1 cycle', '2–3 cycles', '4–6 cycles', '7+ cycles']; function bucketAgingItems(items) { - // Initialise empty buckets with all teams at zero const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; const buckets = {}; for (const b of BUCKET_ORDER) { buckets[b] = { bucket: b, total: 0 }; for (const t of teams) buckets[b][t] = 0; } - - // Classify each item into a bucket for (const item of items) { const sc = item.seen_count; let label; - if (sc === 1) label = '1 cycle'; - else if (sc >= 2 && sc <= 3) label = '2–3 cycles'; - else if (sc >= 4 && sc <= 6) label = '4–6 cycles'; - else label = '7+ cycles'; - - const team = item.team; + if (sc === 1) label = '1 cycle'; + else if (sc >= 2 && sc <= 3) label = '2–3 cycles'; + else if (sc >= 4 && sc <= 6) label = '4–6 cycles'; + else label = '7+ cycles'; buckets[label].total += 1; - if (team in buckets[label]) { - buckets[label][team] += 1; - } + if (item.team in buckets[label]) buckets[label][item.team] += 1; } - - // Return in ascending age order return BUCKET_ORDER.map(b => buckets[b]); } -// --------------------------------------------------------------------------- -// Pure function: compute waterfall chain from ordered upload records -// --------------------------------------------------------------------------- function computeWaterfall(uploads) { let start = 0; return uploads.map((row) => { const end = start + row.new_count + row.recurring_count - row.resolved_count; - const entry = { - date: row.report_date, - start, - new_count: row.new_count, - recurring_count: row.recurring_count, - resolved_count: row.resolved_count, - end, - }; + const entry = { date: row.report_date, start, new_count: row.new_count, recurring_count: row.recurring_count, resolved_count: row.resolved_count, end }; start = end; return entry; }); @@ -297,52 +218,26 @@ function computeWaterfall(uploads) { // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- -function createComplianceRouter(db, upload, requireAuth, requireGroup) { +function createComplianceRouter(upload) { const router = express.Router(); - // Idempotent column additions — errors mean column already exists, which is fine - db.run(`ALTER TABLE compliance_items ADD COLUMN seen_count INTEGER DEFAULT 1`, () => {}); - db.run(`ALTER TABLE compliance_uploads ADD COLUMN summary_json TEXT`, () => {}); - // All compliance routes require authentication - router.use(requireAuth(db)); + router.use(requireAuth()); - // ----------------------------------------------------------------------- // POST /preview - // Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON. - // Returns diff counts + tempFile path for the commit step. - // - // Body: multipart/form-data with `file` field (xlsx) - // Response: { - // drift: { breaking: [], silent_miss: [], cosmetic: [] } | null, - // drift_error: string | null, - // diff: { new_count, recurring_count, resolved_count }, - // tempFile: string, filename: string, - // report_date: string, total_items: number - // } - // ----------------------------------------------------------------------- router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => { upload.single('file')(req, res, async (uploadErr) => { - if (uploadErr) { - return res.status(400).json({ error: uploadErr.message }); - } - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } + if (uploadErr) return res.status(400).json({ error: uploadErr.message }); + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (path.extname(req.file.originalname).toLowerCase() !== '.xlsx') { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'File must be an .xlsx spreadsheet' }); } try { - // --- Drift check: load config, extract schema, compare --- - let drift = null; - let drift_error = null; - + let drift = null, drift_error = null; let config; - try { - config = loadConfig(CONFIG_PATH); - } catch (configErr) { + try { config = loadConfig(CONFIG_PATH); } catch (configErr) { fs.unlink(req.file.path, () => {}); return res.status(500).json({ error: 'Configuration file could not be loaded: ' + configErr.message }); } @@ -350,55 +245,39 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { let xlsxSchema = null; try { xlsxSchema = await extractXlsxSchema(req.file.path); - if (xlsxSchema.error) { - throw new Error(xlsxSchema.error); - } + if (xlsxSchema.error) throw new Error(xlsxSchema.error); drift = compareSchemaToDrift(xlsxSchema, config); } catch (driftErr) { drift = null; drift_error = driftErr.message || 'Drift check failed'; } - // --- Existing parse flow --- const parsed = await parseXlsx(req.file.path); - if (parsed.error) { fs.unlink(req.file.path, () => {}); return res.status(422).json({ error: parsed.error }); } - const diff = await computeDiff(db, parsed.items); + const diff = await computeDiff(parsed.items); - // Save parsed data to temp JSON — the commit step reads this if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true }); const tempFilename = `compliance_preview_${Date.now()}_${Math.random().toString(36).slice(2)}.json`; const tempFilePath = path.join(TEMP_DIR, tempFilename); fs.writeFileSync(tempFilePath, JSON.stringify({ - items: parsed.items, - summary: parsed.summary, + items: parsed.items, summary: parsed.summary, report_date: parsed.report_date, - filename: req.file.originalname.replace(/[^\w.\-() ]/g, '_'), + filename: req.file.originalname.replace(/[^\w.\-() ]/g, '_'), })); - // Delete the original xlsx from temp (we only need the JSON now) fs.unlink(req.file.path, () => {}); res.json({ - drift, - drift_error, - schema: xlsxSchema, - diff: { - new_count: diff.newCount, - recurring_count: diff.recurringCount, - resolved_count: diff.resolvedCount, - }, - tempFile: tempFilePath, - filename: req.file.originalname, - report_date: parsed.report_date, - total_items: parsed.total, + drift, drift_error, schema: xlsxSchema, + diff: { new_count: diff.newCount, recurring_count: diff.recurringCount, resolved_count: diff.resolvedCount }, + tempFile: tempFilePath, filename: req.file.originalname, + report_date: parsed.report_date, total_items: parsed.total, }); - } catch (err) { fs.unlink(req.file.path, () => {}); console.error('[Compliance] Preview error:', err.message); @@ -407,124 +286,64 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { }); }); - // ----------------------------------------------------------------------- // POST /reconcile-config - // Admin-only. Patches compliance_config.json to resolve breaking and - // silent-miss drift findings, then re-runs the drift check and returns - // the updated report. Logs every change to the audit trail. - // - // Body: { drift: { breaking: [...], silent_miss: [...] } } - // Response: { changes: [{ action, key, value, detail }], message: string } - // ----------------------------------------------------------------------- router.post('/reconcile-config', requireGroup('Admin'), async (req, res) => { const { drift, schema } = req.body; - - if (!drift || typeof drift !== 'object') { - return res.status(400).json({ error: 'drift report is required in request body' }); - } - - const hasFindings = (drift.breaking && drift.breaking.length > 0) || - (drift.silent_miss && drift.silent_miss.length > 0); - if (!hasFindings) { - return res.status(400).json({ error: 'No breaking or silent-miss findings to reconcile' }); - } + if (!drift || typeof drift !== 'object') return res.status(400).json({ error: 'drift report is required in request body' }); + const hasFindings = (drift.breaking && drift.breaking.length > 0) || (drift.silent_miss && drift.silent_miss.length > 0); + if (!hasFindings) return res.status(400).json({ error: 'No breaking or silent-miss findings to reconcile' }); try { const { changes } = reconcileConfig(CONFIG_PATH, drift, schema || null); + if (changes.length === 0) return res.json({ changes: [], message: 'No changes needed' }); - if (changes.length === 0) { - return res.json({ changes: [], message: 'No changes needed' }); - } - - // Audit log each change for (const change of changes) { - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'compliance_config_reconcile', - entityType: 'compliance_config', - entityId: change.value, - details: { action: change.action, key: change.key, detail: change.detail }, - ipAddress: req.ip, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'compliance_config_reconcile', entityType: 'compliance_config', entityId: change.value, details: { action: change.action, key: change.key, detail: change.detail }, ipAddress: req.ip }); } - res.json({ changes, message: `Reconciled ${changes.length} config change(s)` }); - } catch (err) { console.error('[Compliance] Reconcile config error:', err.message); res.status(500).json({ error: 'Failed to reconcile config: ' + err.message }); } }); - // ----------------------------------------------------------------------- // POST /commit - // Commit a previewed upload to the DB. - // - // Body: { tempFile: string, filename: string, report_date: string } - // Response: { upload: { id, filename, report_date, uploaded_at, - // new_count, resolved_count, recurring_count } } - // ----------------------------------------------------------------------- router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { tempFile, filename, report_date } = req.body; - - if (!tempFile || typeof tempFile !== 'string') { - return res.status(400).json({ error: 'tempFile is required' }); - } - if (!isSafeTempPath(tempFile)) { - return res.status(400).json({ error: 'Invalid tempFile path' }); - } - if (!fs.existsSync(tempFile)) { - return res.status(400).json({ error: 'Preview session expired — please upload again' }); - } + if (!tempFile || typeof tempFile !== 'string') return res.status(400).json({ error: 'tempFile is required' }); + if (!isSafeTempPath(tempFile)) return res.status(400).json({ error: 'Invalid tempFile path' }); + if (!fs.existsSync(tempFile)) return res.status(400).json({ error: 'Preview session expired — please upload again' }); let parsed; - try { - parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8')); - } catch { - return res.status(400).json({ error: 'Could not read preview data — please upload again' }); - } + try { parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8')); } + catch { return res.status(400).json({ error: 'Could not read preview data — please upload again' }); } try { - const result = await persistUpload(db, { - items: parsed.items, - summary: parsed.summary, + const result = await persistUpload({ + items: parsed.items, summary: parsed.summary, reportDate: report_date || parsed.report_date, - filename: filename || parsed.filename, - userId: req.user?.id || null, + filename: filename || parsed.filename, + userId: req.user?.id || null, }); - fs.unlink(tempFile, () => {}); - const upload = await dbGet(db, - `SELECT id, filename, report_date, uploaded_at, - new_count, resolved_count, recurring_count - FROM compliance_uploads WHERE id = ?`, - [result.uploadId] + const { rows } = await pool.query( + `SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count + FROM compliance_uploads WHERE id = $1`, [result.uploadId] ); - - res.json({ upload }); - + res.json({ upload: rows[0] }); } catch (err) { console.error('[Compliance] Commit error:', err.message); res.status(500).json({ error: 'Failed to commit upload: ' + err.message }); } }); - // ----------------------------------------------------------------------- // GET /uploads - // List all uploads, most recent first. - // - // Response: { uploads: [{ id, filename, report_date, uploaded_at, - // new_count, resolved_count, recurring_count }] } - // ----------------------------------------------------------------------- router.get('/uploads', async (req, res) => { try { - const rows = await dbAll(db, - `SELECT id, filename, report_date, uploaded_at, - new_count, resolved_count, recurring_count - FROM compliance_uploads - ORDER BY id DESC` + const { rows } = await pool.query( + `SELECT id, filename, report_date, uploaded_at, new_count, resolved_count, recurring_count + FROM compliance_uploads ORDER BY id DESC` ); res.json({ uploads: rows }); } catch (err) { @@ -533,410 +352,216 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // POST /rollback/:uploadId - // Admin-only. Rolls back a specific upload. Only the most recent upload - // can be rolled back to avoid cascading data integrity issues. - // - // Params: uploadId — integer ID of the upload to roll back - // Response: { message: string, rolled_back: { upload_id, filename, - // report_date, items_deleted, items_reactivated } } - // - // Reversal logic: - // 1. Delete items first seen in this upload (new items) - // 2. Re-activate items resolved by this upload - // 3. Revert recurring items: decrement seen_count, point upload_id - // back to the previous upload - // 4. Delete the upload record - // ----------------------------------------------------------------------- router.post('/rollback/:uploadId', requireGroup('Admin'), async (req, res) => { const uploadId = parseInt(req.params.uploadId, 10); - if (isNaN(uploadId)) { - return res.status(400).json({ error: 'Invalid upload ID' }); - } + if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' }); try { - // Verify the upload exists - const upload = await dbGet(db, - `SELECT id, filename, report_date, new_count, resolved_count, recurring_count - FROM compliance_uploads WHERE id = ?`, - [uploadId] + const { rows: uploadRows } = await pool.query( + `SELECT id, filename, report_date, new_count, resolved_count, recurring_count FROM compliance_uploads WHERE id = $1`, [uploadId] ); - if (!upload) { - return res.status(404).json({ error: 'Upload not found' }); + const upload = uploadRows[0]; + if (!upload) return res.status(404).json({ error: 'Upload not found' }); + + const { rows: latestRows } = await pool.query(`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1`); + if (latestRows[0].id !== uploadId) { + return res.status(400).json({ error: 'Only the most recent upload can be rolled back', latest_upload_id: latestRows[0].id }); } - // Only allow rolling back the most recent upload - const latest = await dbGet(db, - `SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1` - ); - if (latest.id !== uploadId) { - return res.status(400).json({ - error: 'Only the most recent upload can be rolled back', - latest_upload_id: latest.id - }); - } - - // Find the previous upload (to restore recurring items' upload_id) - const previousUpload = await dbGet(db, - `SELECT id FROM compliance_uploads WHERE id < ? ORDER BY id DESC LIMIT 1`, - [uploadId] - ); - - await dbRun(db, 'BEGIN TRANSACTION'); + const { rows: prevRows } = await pool.query(`SELECT id FROM compliance_uploads WHERE id < $1 ORDER BY id DESC LIMIT 1`, [uploadId]); + const previousUpload = prevRows[0]; + const client = await pool.connect(); try { - // 1. Delete items that were NEW in this upload - const deleteNew = await dbRun(db, - `DELETE FROM compliance_items WHERE first_seen_upload_id = ? AND upload_id = ?`, - [uploadId, uploadId] - ); + await client.query('BEGIN'); - // 2. Re-activate items that were RESOLVED by this upload - const reactivate = await dbRun(db, - `UPDATE compliance_items - SET status = 'active', resolved_upload_id = NULL - WHERE resolved_upload_id = ?`, - [uploadId] + const deleteNew = await client.query( + `DELETE FROM compliance_items WHERE first_seen_upload_id = $1 AND upload_id = $1`, [uploadId] + ); + const reactivate = await client.query( + `UPDATE compliance_items SET status = 'active', resolved_upload_id = NULL WHERE resolved_upload_id = $1`, [uploadId] ); - - // 3. Revert RECURRING items: decrement seen_count, restore upload_id if (previousUpload) { - await dbRun(db, - `UPDATE compliance_items - SET upload_id = ?, seen_count = MAX(seen_count - 1, 1) - WHERE upload_id = ? AND first_seen_upload_id != ?`, - [previousUpload.id, uploadId, uploadId] + await client.query( + `UPDATE compliance_items SET upload_id = $1, seen_count = GREATEST(seen_count - 1, 1) WHERE upload_id = $2 AND first_seen_upload_id != $2`, + [previousUpload.id, uploadId] ); } + await client.query(`DELETE FROM compliance_uploads WHERE id = $1`, [uploadId]); + await client.query('COMMIT'); - // 4. Delete the upload record - await dbRun(db, `DELETE FROM compliance_uploads WHERE id = ?`, [uploadId]); - - await dbRun(db, 'COMMIT'); - - // Audit log - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'compliance_upload_rollback', - entityType: 'compliance_upload', - entityId: String(uploadId), - details: { - filename: upload.filename, - report_date: upload.report_date, - items_deleted: deleteNew.changes, - items_reactivated: reactivate.changes, - }, - ipAddress: req.ip, - }); - - res.json({ - message: `Rolled back upload "${upload.filename}"`, - rolled_back: { - upload_id: uploadId, - filename: upload.filename, - report_date: upload.report_date, - items_deleted: deleteNew.changes, - items_reactivated: reactivate.changes, - }, - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'compliance_upload_rollback', entityType: 'compliance_upload', entityId: String(uploadId), details: { filename: upload.filename, report_date: upload.report_date, items_deleted: deleteNew.rowCount, items_reactivated: reactivate.rowCount }, ipAddress: req.ip }); + res.json({ message: `Rolled back upload "${upload.filename}"`, rolled_back: { upload_id: uploadId, filename: upload.filename, report_date: upload.report_date, items_deleted: deleteNew.rowCount, items_reactivated: reactivate.rowCount } }); } catch (err) { - await dbRun(db, 'ROLLBACK').catch(() => {}); + await client.query('ROLLBACK'); throw err; + } finally { + client.release(); } - } catch (err) { console.error('[Compliance] Rollback error:', err.message); res.status(500).json({ error: 'Failed to rollback upload: ' + err.message }); } }); - // ----------------------------------------------------------------------- - // GET /summary?team=STEAM - // Return metric health rows for a team from the latest upload's summary_json. - // - // Query: team — optional, one of ALLOWED_TEAMS - // Response: { entries: [...], overall_scores: {}, upload: { id, - // report_date, uploaded_at } | null } - // ----------------------------------------------------------------------- + // GET /summary router.get('/summary', async (req, res) => { const team = req.query.team; - if (team && !ALLOWED_TEAMS.has(team)) { - return res.status(400).json({ error: 'Invalid team' }); - } + if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); try { - const latestUpload = await dbGet(db, - `SELECT id, summary_json, report_date, uploaded_at - FROM compliance_uploads ORDER BY id DESC LIMIT 1` + const { rows: latestRows } = await pool.query( + `SELECT id, summary_json, report_date, uploaded_at FROM compliance_uploads ORDER BY id DESC LIMIT 1` ); - if (!latestUpload || !latestUpload.summary_json) { - return res.json({ entries: [], overall_scores: {}, upload: null }); - } + const latestUpload = latestRows[0]; + if (!latestUpload || !latestUpload.summary_json) return res.json({ entries: [], overall_scores: {}, upload: null }); let summary; - try { summary = JSON.parse(latestUpload.summary_json); } - catch { return res.json({ entries: [], overall_scores: {}, upload: null }); } + try { summary = JSON.parse(latestUpload.summary_json); } catch { return res.json({ entries: [], overall_scores: {}, upload: null }); } let entries = summary.entries || []; - if (team) { - entries = entries.filter(e => e.team === team); - } + if (team) entries = entries.filter(e => e.team === team); - res.json({ - entries, - overall_scores: summary.overall_scores || {}, - upload: { - id: latestUpload.id, - report_date: latestUpload.report_date, - uploaded_at: latestUpload.uploaded_at, - }, - }); + res.json({ entries, overall_scores: summary.overall_scores || {}, upload: { id: latestUpload.id, report_date: latestUpload.report_date, uploaded_at: latestUpload.uploaded_at } }); } catch (err) { console.error('[Compliance] GET /summary error:', err.message); res.status(500).json({ error: 'Database error' }); } }); - // ----------------------------------------------------------------------- - // GET /items?team=STEAM&status=active - // Return non-compliant devices grouped by hostname. - // - // Query: team — required, one of ALLOWED_TEAMS - // status — optional, 'active' (default) or 'resolved' - // Response: { devices: [{ hostname, ip_address, device_type, team, - // status, failing_metrics, seen_count, first_seen, last_seen, - // resolved_on, has_notes }], team, status } - // ----------------------------------------------------------------------- + // GET /items router.get('/items', async (req, res) => { const { team, status = 'active' } = req.query; - if (!team) return res.status(400).json({ error: 'team is required' }); if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' }); try { - const rows = await dbAll(db, - `SELECT - ci.hostname, ci.ip_address, ci.device_type, ci.team, - ci.metric_id, ci.metric_desc, ci.category, - ci.status, ci.seen_count, - fu.report_date AS first_seen, - lu.report_date AS last_seen, - ru.report_date AS resolved_on + const { rows } = await pool.query( + `SELECT ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count, + fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id - LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id - LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id - WHERE ci.team = ? AND ci.status = ? + LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id + LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id + WHERE ci.team = $1 AND ci.status = $2 ORDER BY ci.hostname, ci.metric_id`, [team, status] ); - // Fetch hostnames that have any notes (for the has_notes indicator) - const noteRows = await dbAll(db, - `SELECT DISTINCT hostname FROM compliance_notes` - ); + const { rows: noteRows } = await pool.query(`SELECT DISTINCT hostname FROM compliance_notes`); const noteHostnames = new Set(noteRows.map(r => r.hostname)); - const devices = groupByHostname(rows, noteHostnames); - res.json({ devices, team, status }); - } catch (err) { console.error('[Compliance] GET /items error:', err.message); res.status(500).json({ error: 'Database error' }); } }); - // ----------------------------------------------------------------------- // GET /items/:hostname - // Detail panel: all metric rows for this hostname + notes + upload history. - // - // Params: hostname — device hostname string - // Response: { hostname, ip_address, device_type, team, - // metrics: [{ metric_id, metric_desc, category, status, seen_count, - // extra, first_seen, last_seen, resolved_on, ... }], - // notes: [{ id, metric_id, note, group_id, created_at, created_by }] } - // ----------------------------------------------------------------------- router.get('/items/:hostname', async (req, res) => { const hostname = req.params.hostname; - if (!hostname || hostname.length > 300) { - return res.status(400).json({ error: 'Invalid hostname' }); - } + if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); try { - // All metric rows for this hostname - const metricRows = await dbAll(db, - `SELECT - ci.metric_id, ci.metric_desc, ci.category, ci.status, - ci.ip_address, ci.device_type, ci.team, - ci.seen_count, ci.extra_json, - fu.report_date AS first_seen, - fu.uploaded_at AS first_seen_at, - lu.report_date AS last_seen, - lu.uploaded_at AS last_seen_at, - ru.report_date AS resolved_on + const { rows: metricRows } = await pool.query( + `SELECT ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json, + fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at, lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at, ru.report_date AS resolved_on FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id - LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id - LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id - WHERE ci.hostname = ? - ORDER BY ci.status DESC, ci.metric_id`, - [hostname] + LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id + LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id + WHERE ci.hostname = $1 + ORDER BY ci.status DESC, ci.metric_id`, [hostname] + ); + if (metricRows.length === 0) return res.status(404).json({ error: 'Device not found' }); + + const metrics = metricRows.map(r => ({ ...r, extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(), extra_json: undefined })); + + const { rows: notes } = await pool.query( + `SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at, u.username AS created_by + FROM compliance_notes cn LEFT JOIN users u ON cn.created_by = u.id + WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname] ); - if (metricRows.length === 0) { - return res.status(404).json({ error: 'Device not found' }); - } - - // Parse extra_json on each row - const metrics = metricRows.map(r => ({ - ...r, - extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(), - extra_json: undefined, - })); - - // Notes (all metrics for this hostname, sorted newest first) - const notes = await dbAll(db, - `SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at, - u.username AS created_by - FROM compliance_notes cn - LEFT JOIN users u ON cn.created_by = u.id - WHERE cn.hostname = ? - ORDER BY cn.created_at DESC`, - [hostname] - ); - - // Derive device identity from the first active row, else any row const identity = metricRows.find(r => r.status === 'active') || metricRows[0]; - - res.json({ - hostname, - ip_address: identity.ip_address || '', - device_type: identity.device_type || '', - team: identity.team || '', - metrics, - notes, - }); - + res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', metrics, notes }); } catch (err) { console.error('[Compliance] GET /items/:hostname error:', err.message); res.status(500).json({ error: 'Database error' }); } }); - // ----------------------------------------------------------------------- // POST /notes - // Add a note to one or more (hostname, metric_id) pairs. - // - // Body: { hostname: string, metric_ids: string[], note: string } - // — or legacy: { hostname: string, metric_id: string, note: string } - // Response: { notes: [{ id, hostname, metric_id, note, group_id, - // created_at, created_by }] } - // ----------------------------------------------------------------------- router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => { const { hostname, metric_id, metric_ids, note } = req.body; + if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) return res.status(400).json({ error: 'Invalid hostname format' }); - if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) { - return res.status(400).json({ error: 'Invalid hostname format' }); - } - - // --- Resolve metric IDs: metric_ids takes precedence over metric_id --- let resolvedIds; if (metric_ids !== undefined) { - if (!Array.isArray(metric_ids)) { - return res.status(400).json({ error: 'metric_ids must be an array' }); - } + if (!Array.isArray(metric_ids)) return res.status(400).json({ error: 'metric_ids must be an array' }); resolvedIds = metric_ids; } else if (metric_id !== undefined && metric_id !== null && metric_id !== '') { - if (typeof metric_id !== 'string' || metric_id.length > 50) { - return res.status(400).json({ error: 'Invalid metric_id' }); - } + if (typeof metric_id !== 'string' || metric_id.length > 50) return res.status(400).json({ error: 'Invalid metric_id' }); resolvedIds = [metric_id]; } else { return res.status(400).json({ error: 'metric_id or metric_ids is required' }); } - // --- Validate resolved metric IDs --- - if (resolvedIds.length === 0) { - return res.status(400).json({ error: 'At least one metric ID is required' }); - } + if (resolvedIds.length === 0) return res.status(400).json({ error: 'At least one metric ID is required' }); for (let i = 0; i < resolvedIds.length; i++) { const mid = resolvedIds[i]; - if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) { - return res.status(400).json({ error: `Invalid metric_id at index ${i}` }); - } + if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) return res.status(400).json({ error: `Invalid metric_id at index ${i}` }); } const noteText = String(note || '').trim().slice(0, 1000); - if (!noteText) { - return res.status(400).json({ error: 'Note cannot be empty' }); - } + if (!noteText) return res.status(400).json({ error: 'Note cannot be empty' }); const groupId = crypto.randomUUID(); const userId = req.user?.id || null; + const client = await pool.connect(); try { - await dbRun(db, 'BEGIN TRANSACTION'); - + await client.query('BEGIN'); const insertedIds = []; for (const mid of resolvedIds) { - const { lastID } = await dbRun(db, - `INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at) - VALUES (?, ?, ?, ?, ?, datetime('now'))`, + const { rows } = await client.query( + `INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at) VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING id`, [hostname, mid, noteText, groupId, userId] ); - insertedIds.push(lastID); + insertedIds.push(rows[0].id); } + await client.query('COMMIT'); - await dbRun(db, 'COMMIT'); - - // Fetch all created rows with username - const placeholders = insertedIds.map(() => '?').join(', '); - const notes = await dbAll(db, - `SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at, - u.username AS created_by - FROM compliance_notes cn - LEFT JOIN users u ON cn.created_by = u.id - WHERE cn.id IN (${placeholders}) - ORDER BY cn.id ASC`, - insertedIds + const { rows: notes } = await pool.query( + `SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at, u.username AS created_by + FROM compliance_notes cn LEFT JOIN users u ON cn.created_by = u.id + WHERE cn.id = ANY($1) ORDER BY cn.id ASC`, [insertedIds] ); - res.status(201).json({ notes }); - } catch (err) { - await dbRun(db, 'ROLLBACK').catch(() => {}); + await client.query('ROLLBACK'); console.error('[Compliance] POST /notes error:', err.message); res.status(500).json({ error: 'Failed to save note' }); + } finally { + client.release(); } }); - // ----------------------------------------------------------------------- // GET /notes/:hostname/:metricId - // Return all notes for a (hostname, metric_id) pair. - // - // Params: hostname — device hostname string - // metricId — metric identifier string - // Response: { notes: [{ id, note, created_at, created_by }] } - // ----------------------------------------------------------------------- router.get('/notes/:hostname/:metricId', async (req, res) => { const { hostname, metricId } = req.params; - if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' }); - if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' }); + if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' }); try { - const notes = await dbAll(db, + const { rows: notes } = await pool.query( `SELECT cn.id, cn.note, cn.created_at, u.username AS created_by - FROM compliance_notes cn - LEFT JOIN users u ON cn.created_by = u.id - WHERE cn.hostname = ? AND cn.metric_id = ? - ORDER BY cn.created_at DESC`, - [hostname, metricId] + FROM compliance_notes cn LEFT JOIN users u ON cn.created_by = u.id + WHERE cn.hostname = $1 AND cn.metric_id = $2 ORDER BY cn.created_at DESC`, [hostname, metricId] ); res.json({ notes }); } catch (err) { @@ -945,61 +570,31 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // DELETE /notes/:id - // Delete a note (or all notes in the same group_id) by note ID. - // Only the note author or an Admin can delete. - // - // Params: id — note row ID - // Query: ?group=true — delete all notes sharing the same group_id - // Response: { deleted: number } - // ----------------------------------------------------------------------- router.delete('/notes/:id', requireGroup('Admin', 'Standard_User'), async (req, res) => { const noteId = parseInt(req.params.id, 10); if (isNaN(noteId)) return res.status(400).json({ error: 'Invalid note ID' }); - const deleteGroup = req.query.group === 'true'; try { - // Fetch the note to verify ownership - const note = await dbGet(db, - `SELECT id, hostname, metric_id, note, group_id, created_by FROM compliance_notes WHERE id = ?`, - [noteId] - ); - if (!note) return res.status(404).json({ error: 'Note not found' }); + const { rows } = await pool.query(`SELECT id, hostname, metric_id, note, group_id, created_by FROM compliance_notes WHERE id = $1`, [noteId]); + const noteRow = rows[0]; + if (!noteRow) return res.status(404).json({ error: 'Note not found' }); - // Only the author or an Admin can delete - const isAuthor = req.user && String(req.user.id) === String(note.created_by); + const isAuthor = req.user && String(req.user.id) === String(noteRow.created_by); const isAdminUser = req.user && req.user.group === 'Admin'; - if (!isAuthor && !isAdminUser) { - return res.status(403).json({ error: 'You can only delete your own notes' }); - } + if (!isAuthor && !isAdminUser) return res.status(403).json({ error: 'You can only delete your own notes' }); let deleted = 0; - if (deleteGroup && note.group_id) { - const result = await dbRun(db, - `DELETE FROM compliance_notes WHERE group_id = ?`, - [note.group_id] - ); - deleted = result.changes || 0; + if (deleteGroup && noteRow.group_id) { + const result = await pool.query(`DELETE FROM compliance_notes WHERE group_id = $1`, [noteRow.group_id]); + deleted = result.rowCount; } else { - const result = await dbRun(db, - `DELETE FROM compliance_notes WHERE id = ?`, - [noteId] - ); - deleted = result.changes || 0; + const result = await pool.query(`DELETE FROM compliance_notes WHERE id = $1`, [noteId]); + deleted = result.rowCount; } - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'compliance_note_delete', - entityType: 'compliance_note', - entityId: String(noteId), - details: JSON.stringify({ hostname: note.hostname, group_id: note.group_id, deleted_count: deleted }), - ipAddress: req.ip, - }); - + logAudit({ userId: req.user.id, username: req.user.username, action: 'compliance_note_delete', entityType: 'compliance_note', entityId: String(noteId), details: JSON.stringify({ hostname: noteRow.hostname, group_id: noteRow.group_id, deleted_count: deleted }), ipAddress: req.ip }); res.json({ deleted }); } catch (err) { console.error('[Compliance] DELETE /notes error:', err.message); @@ -1007,56 +602,24 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // GET /trends - // Per-upload active totals + per-team counts for time-series charts. - // Returns rows ordered ascending by report_date. - // - // Response: { trends: [{ report_date, new_count, recurring_count, - // resolved_count, total_active, STEAM, ACCESS-ENG, ACCESS-OPS, - // INTELDEV }] } - // ----------------------------------------------------------------------- router.get('/trends', async (req, res) => { try { - const uploads = await dbAll(db, - `SELECT id, report_date, - COALESCE(new_count, 0) AS new_count, - COALESCE(recurring_count, 0) AS recurring_count, - COALESCE(resolved_count, 0) AS resolved_count, - COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active - FROM compliance_uploads - ORDER BY report_date ASC` + const { rows: uploads } = await pool.query( + `SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count, COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active FROM compliance_uploads ORDER BY report_date ASC` ); - if (uploads.length === 0) return res.json({ trends: [] }); - // Per-team active counts — items whose upload_id matches the upload - // (recurring items have upload_id bumped each cycle, so this is accurate) - const teamRows = await dbAll(db, - `SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count - FROM compliance_items ci - WHERE ci.team IS NOT NULL - GROUP BY ci.upload_id, ci.team` + const { rows: teamRows } = await pool.query( + `SELECT ci.upload_id, ci.team, COUNT(ci.id)::int AS count FROM compliance_items ci WHERE ci.team IS NOT NULL GROUP BY ci.upload_id, ci.team` ); - const teamMap = {}; - teamRows.forEach(r => { - if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {}; - teamMap[r.upload_id][r.team] = r.count; - }); + teamRows.forEach(r => { if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {}; teamMap[r.upload_id][r.team] = r.count; }); const trends = uploads.map(u => ({ - report_date: u.report_date, - new_count: u.new_count, - recurring_count: u.recurring_count, - resolved_count: u.resolved_count, - total_active: u.total_active, - STEAM: teamMap[u.id]?.STEAM || 0, - 'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0, - 'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0, - INTELDEV: teamMap[u.id]?.INTELDEV || 0, + report_date: u.report_date, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, total_active: u.total_active, + STEAM: teamMap[u.id]?.STEAM || 0, 'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0, 'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0, INTELDEV: teamMap[u.id]?.INTELDEV || 0, })); - res.json({ trends }); } catch (err) { console.error('[Compliance] GET /trends error:', err.message); @@ -1064,23 +627,11 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // GET /mttr - // Aging Findings Distribution — active findings bucketed by seen_count - // with per-team breakdown for stacked bar chart. - // - // Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] } - // ----------------------------------------------------------------------- router.get('/mttr', async (req, res) => { try { - const rows = await dbAll(db, - `SELECT COALESCE(seen_count, 1) AS seen_count, team - FROM compliance_items - WHERE status = 'active'` - ); - if (rows.length === 0) { - return res.json({ aging: [] }); - } + const { rows } = await pool.query(`SELECT COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active'`); + if (rows.length === 0) return res.json({ aging: [] }); const aging = bucketAgingItems(rows); res.json({ aging }); } catch (err) { @@ -1089,23 +640,11 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // GET /top-recurring - // Net Change Waterfall — per-cycle net movement (start → +new → - // +recurring → −resolved → end) computed from compliance_uploads. - // - // Response: { waterfall: [{ date, start, new_count, recurring_count, - // resolved_count, end }] } - // ----------------------------------------------------------------------- router.get('/top-recurring', async (req, res) => { try { - const rows = await dbAll(db, - `SELECT id, report_date, - COALESCE(new_count, 0) AS new_count, - COALESCE(recurring_count, 0) AS recurring_count, - COALESCE(resolved_count, 0) AS resolved_count - FROM compliance_uploads - ORDER BY report_date ASC` + const { rows } = await pool.query( + `SELECT id, report_date, COALESCE(new_count, 0) AS new_count, COALESCE(recurring_count, 0) AS recurring_count, COALESCE(resolved_count, 0) AS resolved_count FROM compliance_uploads ORDER BY report_date ASC` ); const waterfall = computeWaterfall(rows); res.json({ waterfall }); @@ -1115,20 +654,13 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) { } }); - // ----------------------------------------------------------------------- // GET /category-trend - // Active item counts per category per upload, for stacked area chart. - // - // Response: { categoryTrend: [{ report_date, category, count }] } - // ----------------------------------------------------------------------- router.get('/category-trend', async (req, res) => { try { - const rows = await dbAll(db, - `SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count - FROM compliance_uploads cu - JOIN compliance_items ci ON ci.upload_id = cu.id - GROUP BY cu.id, category - ORDER BY cu.report_date ASC` + const { rows } = await pool.query( + `SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id)::int AS count + FROM compliance_uploads cu JOIN compliance_items ci ON ci.upload_id = cu.id + GROUP BY cu.id, cu.report_date, category ORDER BY cu.report_date ASC` ); res.json({ categoryTrend: rows }); } catch (err) { diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js index ffa9dc4..15a6cdd 100644 --- a/backend/routes/feedback.js +++ b/backend/routes/feedback.js @@ -4,26 +4,16 @@ const express = require('express'); const https = require('https'); const http = require('http'); +const { requireAuth } = require('../middleware/auth'); -function createFeedbackRouter(db, requireAuth) { +function createFeedbackRouter() { const router = express.Router(); const GITLAB_URL = process.env.GITLAB_URL || ''; const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || ''; const GITLAB_PAT = process.env.GITLAB_PAT || ''; - /** - * POST /api/feedback - * - * Create a GitLab issue from a bug report or feature request. - * Available to all authenticated users. - * - * @body {string} type - "bug" or "feature" - * @body {string} title - Issue title - * @body {string} description - Issue description - * @body {string} [page] - Which dashboard page the user was on - */ - router.post('/', requireAuth, async (req, res) => { + router.post('/', requireAuth(), async (req, res) => { if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) { return res.status(503).json({ error: 'Feedback integration not configured' }); } diff --git a/backend/routes/ivantiArchive.js b/backend/routes/ivantiArchive.js index 4e37644..f22dd20 100644 --- a/backend/routes/ivantiArchive.js +++ b/backend/routes/ivantiArchive.js @@ -1,19 +1,12 @@ // Ivanti Archive Routes — list, stats, and transition history for archived findings const express = require('express'); +const pool = require('../db'); +const { requireAuth } = require('../middleware/auth'); const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; /** * Find the most severe active finding related to an archived finding. - * - * A match requires: - * - Exact hostname match (case-sensitive) - * - The archive title is a case-insensitive substring of the active title, or vice versa - * - The active finding ID differs from the archive's finding_id - * - * @param {Object} archive - Archive record from ivanti_finding_archives - * @param {Array} activeFindings - Parsed entries from ivanti_findings_cache - * @returns {{ id: string, title: string, severity: number } | null} */ function findRelatedActive(archive, activeFindings) { const archiveTitle = (archive.finding_title || '').toLowerCase(); @@ -34,21 +27,13 @@ function findRelatedActive(archive, activeFindings) { return { id: best.id, title: best.title, severity: best.severity }; } -function createIvantiArchiveRouter(db, requireAuth) { +function createIvantiArchiveRouter() { const router = express.Router(); // All routes require authentication - router.use(requireAuth(db)); + router.use(requireAuth()); - /** - * GET / - * List archive records with optional state filtering. - * - * @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED) - * @returns {Object} 200 - { archives: Array, total: number } - * @returns {Object} 400 - { error: string } when state param is invalid - * @returns {Object} 500 - { error: string } on database failure - */ + // GET / — List archive records with optional state filtering router.get('/', async (req, res) => { const { state } = req.query; @@ -61,43 +46,27 @@ function createIvantiArchiveRouter(db, requireAuth) { try { let query = 'SELECT * FROM ivanti_finding_archives'; const params = []; + let paramIndex = 1; if (state) { - query += ' WHERE current_state = ?'; + query += ` WHERE current_state = $${paramIndex++}`; params.push(state); } query += ' ORDER BY last_transition_at DESC'; - const archives = await new Promise((resolve, reject) => { - db.all(query, params, (err, rows) => { - if (err) reject(err); - else resolve(rows || []); - }); - }); + const { rows: archives } = await pool.query(query, params); - // Fetch and parse active findings cache for related-finding enrichment + // Fetch active findings for related-finding enrichment + // In the new schema, active findings are in ivanti_findings table let activeFindings = []; try { - const cacheRow = await new Promise((resolve, reject) => { - db.get( - 'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1', - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); - - if (cacheRow && cacheRow.findings_json) { - activeFindings = JSON.parse(cacheRow.findings_json); - } + const { rows: findingsRows } = await pool.query( + `SELECT id, title, host_name AS "hostName", severity FROM ivanti_findings WHERE state = 'open'` + ); + activeFindings = findingsRows; } catch (cacheErr) { - console.warn('Failed to load findings cache for related-active matching:', cacheErr); - } - - if (!Array.isArray(activeFindings)) { - activeFindings = []; + console.warn('Failed to load findings for related-active matching:', cacheErr); } // Enrich each archive record with related active finding info @@ -113,52 +82,28 @@ function createIvantiArchiveRouter(db, requireAuth) { } }); - /** - * GET /stats - * Summary counts of archive records by lifecycle state. - * ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record. - * - * @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } - * @returns {Object} 500 - { error: string } on database failure - */ + // GET /stats — Summary counts by lifecycle state router.get('/stats', async (req, res) => { try { - // Count archive records by state - const rows = await new Promise((resolve, reject) => { - db.all( - `SELECT current_state, COUNT(*) as count - FROM ivanti_finding_archives - GROUP BY current_state`, - (err, rows) => { - if (err) reject(err); - else resolve(rows || []); - } - ); - }); + const { rows } = await pool.query( + `SELECT current_state, COUNT(*) as count + FROM ivanti_finding_archives + GROUP BY current_state` + ); const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; for (const row of rows) { if (stats.hasOwnProperty(row.current_state)) { - stats[row.current_state] = row.count; + stats[row.current_state] = parseInt(row.count); } } - // Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records - const cacheRow = await new Promise((resolve, reject) => { - db.get( - 'SELECT total FROM ivanti_findings_cache WHERE id = 1', - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); - - const liveFindingsCount = (cacheRow && cacheRow.total) || 0; - // Findings that are ARCHIVED or RETURNED are "missing" from the live set, - // so ACTIVE = live count (all findings currently present in sync results) - stats.ACTIVE = liveFindingsCount; + // ACTIVE = total live findings count + const countResult = await pool.query( + `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'` + ); + stats.ACTIVE = parseInt(countResult.rows[0].total) || 0; const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED; @@ -169,46 +114,27 @@ function createIvantiArchiveRouter(db, requireAuth) { } }); - /** - * GET /:findingId/history - * Transition history for a specific archived finding, ordered by most recent first. - * Returns an empty transitions array if the finding has no archive record. - * - * @param {string} findingId - Ivanti finding identifier (route param) - * @returns {Object} 200 - { finding_id: string, transitions: Array } - * @returns {Object} 500 - { error: string } on database failure - */ + // GET /:findingId/history — Transition history for a specific archived finding router.get('/:findingId/history', async (req, res) => { const { findingId } = req.params; try { - const archive = await new Promise((resolve, reject) => { - db.get( - 'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?', - [findingId], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows: archiveRows } = await pool.query( + 'SELECT id FROM ivanti_finding_archives WHERE finding_id = $1', + [findingId] + ); + const archive = archiveRows[0]; if (!archive) { return res.json({ finding_id: findingId, transitions: [] }); } - const transitions = await new Promise((resolve, reject) => { - db.all( - `SELECT * FROM ivanti_archive_transitions - WHERE archive_id = ? - ORDER BY transitioned_at DESC`, - [archive.id], - (err, rows) => { - if (err) reject(err); - else resolve(rows || []); - } - ); - }); + const { rows: transitions } = await pool.query( + `SELECT * FROM ivanti_archive_transitions + WHERE archive_id = $1 + ORDER BY transitioned_at DESC`, + [archive.id] + ); res.json({ finding_id: findingId, transitions }); } catch (err) { diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index a77c831..988bfbc 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -3,7 +3,8 @@ const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); -const { requireGroup } = require('../middleware/auth'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi'); const logAudit = require('../helpers/auditLog'); @@ -16,44 +17,26 @@ const ALLOWED_EXTENSIONS = new Set([ '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip' ]); -/** - * Returns true if the filename has an allowed extension (case-insensitive). - */ function isAllowedFileExtension(filename) { if (!filename || typeof filename !== 'string') return false; const ext = path.extname(filename).toLowerCase(); return ALLOWED_EXTENSIONS.has(ext); } -/** - * Validates the FP workflow form body. - * Returns {} if valid, or { fieldName: 'error message' } for each invalid field. - */ function validateFpWorkflowForm(body) { const errors = {}; - - // name: required, non-empty, max 255 if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) { errors.name = 'Workflow name is required.'; } else if (body.name.trim().length > 255) { errors.name = 'Workflow name must be 255 characters or fewer.'; } - - // reason: required, non-empty if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) { errors.reason = 'Reason is required.'; } - - // description: optional, max 2000 if provided if (body.description !== undefined && body.description !== null && body.description !== '') { - if (typeof body.description !== 'string') { - errors.description = 'Description must be a string.'; - } else if (body.description.length > 2000) { - errors.description = 'Description must be 2000 characters or fewer.'; - } + if (typeof body.description !== 'string') errors.description = 'Description must be a string.'; + else if (body.description.length > 2000) errors.description = 'Description must be 2000 characters or fewer.'; } - - // expirationDate: required, valid date, strictly after today if (!body.expirationDate || typeof body.expirationDate !== 'string' || body.expirationDate.trim().length === 0) { errors.expirationDate = 'Expiration date is required.'; } else { @@ -61,53 +44,21 @@ function validateFpWorkflowForm(body) { if (isNaN(parsed.getTime())) { errors.expirationDate = 'Expiration date must be a valid date.'; } else { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const expDay = new Date(parsed); - expDay.setHours(0, 0, 0, 0); - if (expDay <= today) { - errors.expirationDate = 'Expiration date must be in the future.'; - } else { - const maxDate = new Date(today); - maxDate.setDate(maxDate.getDate() + 120); - if (expDay > maxDate) { - errors.expirationDate = 'Expiration date cannot be more than 120 days from today.'; - } - } + const today = new Date(); today.setHours(0, 0, 0, 0); + const expDay = new Date(parsed); expDay.setHours(0, 0, 0, 0); + if (expDay <= today) errors.expirationDate = 'Expiration date must be in the future.'; + else { const maxDate = new Date(today); maxDate.setDate(maxDate.getDate() + 120); if (expDay > maxDate) errors.expirationDate = 'Expiration date cannot be more than 120 days from today.'; } } } - return errors; } -/** - * Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint. - * Format: { subject, filterRequest: { filters } } - */ function buildSubjectFilterRequest(findingIds) { - return JSON.stringify({ - subject: 'hostFinding', - filterRequest: { - filters: [{ - field: 'id', - exclusive: false, - operator: 'IN', - value: findingIds.map(id => String(id)).join(',') - }] - } - }); + return JSON.stringify({ subject: 'hostFinding', filterRequest: { filters: [{ field: 'id', exclusive: false, operator: 'IN', value: findingIds.map(id => String(id)).join(',') }] } }); } -/** - * Builds the multipart form fields array for the Ivanti FP workflow request. - */ function buildIvantiFormFields(formData, findingIds) { - const scopeMap = { - 'Authorized': 'AUTHORIZED', - 'None': 'NONE', - 'Automated': 'AUTOMATED' - }; - + const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' }; return [ { name: 'name', value: formData.name }, { name: 'reason', value: formData.reason }, @@ -119,634 +70,246 @@ function buildIvantiFormFields(formData, findingIds) { ]; } -const LIFECYCLE_STATUSES = new Set([ - 'submitted', 'approved', 'rejected', 'rework', 'resubmitted' -]); +const LIFECYCLE_STATUSES = new Set(['submitted', 'approved', 'rejected', 'rework', 'resubmitted']); -/** - * Validates whether a lifecycle status transition is allowed. - * Returns { valid: true } or { valid: false, error: string }. - */ function validateLifecycleTransition(currentStatus, newStatus) { - if (currentStatus === 'approved') { - return { valid: false, error: 'This submission is finalized and cannot be edited.' }; - } - if (!LIFECYCLE_STATUSES.has(newStatus)) { - return { valid: false, error: 'Invalid lifecycle status.' }; - } + if (currentStatus === 'approved') return { valid: false, error: 'This submission is finalized and cannot be edited.' }; + if (!LIFECYCLE_STATUSES.has(newStatus)) return { valid: false, error: 'Invalid lifecycle status.' }; return { valid: true }; } -/** - * Merges existing finding IDs (JSON string) with new IDs (array), deduplicates, - * and returns the merged array as a JSON string. - */ function mergeFindings(existingJson, newIds) { const existing = JSON.parse(existingJson || '[]'); const merged = [...new Set([...existing, ...newIds])]; return JSON.stringify(merged); } -/** - * Builds a submission history entry object. - * The caller is responsible for setting submission_id on the returned object. - */ function buildSubmissionHistoryEntry(changeType, details, userId, username) { - return { - user_id: userId, - username: username, - change_type: changeType, - change_details_json: JSON.stringify(details), - created_at: new Date().toISOString() - }; + return { user_id: userId, username: username, change_type: changeType, change_details_json: JSON.stringify(details), created_at: new Date().toISOString() }; } // --------------------------------------------------------------------------- -// Resolve workflow batch UUID — looks it up via Ivanti search if not stored locally +// Resolve workflow batch UUID // --------------------------------------------------------------------------- -async function resolveWorkflowBatchUuid(db, submission, apiKey, clientId, skipTls) { - // Return cached UUID if available +async function resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls) { if (submission.ivanti_workflow_batch_uuid) return submission.ivanti_workflow_batch_uuid; const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; - const batchId = String(submission.ivanti_workflow_batch_id); const workflowName = submission.workflow_name || ''; - - // Search by workflow name (the 'id' field in search context doesn't match the numeric batch ID) - const searchBody = { - filters: workflowName ? [ - { field: 'name', exclusive: false, operator: 'EXACT', value: workflowName } - ] : [], - projection: 'internal', - sort: [{ field: 'created', direction: 'DESC' }], - page: 0, - size: 10 - }; + const searchBody = { filters: workflowName ? [{ field: 'name', exclusive: false, operator: 'EXACT', value: workflowName }] : [], projection: 'internal', sort: [{ field: 'created', direction: 'DESC' }], page: 0, size: 10 }; let result; - try { - result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); - } catch (e) { - console.error('[resolveUUID] Search request failed:', e.message); - return null; - } - - if (result.status !== 200) { - console.error('[resolveUUID] Search returned status:', result.status); - return null; - } + try { result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); } catch (e) { return null; } + if (result.status !== 200) return null; let uuid = null; try { const data = JSON.parse(result.body); - let batches = []; - if (data._embedded?.workflowBatches) batches = data._embedded.workflowBatches; - else if (data._embedded?.workflowBatch) batches = data._embedded.workflowBatch; - else if (data.content) batches = data.content; - else if (data.data) batches = data.data; - else if (Array.isArray(data)) batches = data; - + let batches = data._embedded?.workflowBatches || data._embedded?.workflowBatch || data.content || data.data || (Array.isArray(data) ? data : []); + const batchId = String(submission.ivanti_workflow_batch_id); const batch = batches.find(b => String(b.id) === batchId) || batches[0]; - if (batch) { - uuid = batch.uuid || batch.workflowBatchUuid || batch.batchUuid || batch.groupUuid - || batch.group_uuid || batch.workflow_batch_uuid || batch.uid || null; - } - } catch (e) { - console.error('[resolveUUID] Failed to parse search response:', e.message); - return null; - } + if (batch) uuid = batch.uuid || batch.workflowBatchUuid || batch.batchUuid || batch.groupUuid || batch.group_uuid || batch.workflow_batch_uuid || batch.uid || null; + } catch (e) { return null; } - // Cache the UUID locally for future use if (uuid && submission.id) { - db.run( - `UPDATE ivanti_fp_submissions SET ivanti_workflow_batch_uuid = ? WHERE id = ?`, - [uuid, submission.id], - (err) => { if (err) console.error('Failed to cache workflow batch UUID:', err); } - ); + pool.query(`UPDATE ivanti_fp_submissions SET ivanti_workflow_batch_uuid = $1 WHERE id = $2`, [uuid, submission.id]).catch(() => {}); } - return uuid; } // --------------------------------------------------------------------------- // Multer configuration // --------------------------------------------------------------------------- - const uploadStorage = multer.memoryStorage(); - -const fpUpload = multer({ - storage: uploadStorage, - limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file - fileFilter: (req, file, cb) => { - if (isAllowedFileExtension(file.originalname)) { - cb(null, true); - } else { - cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`)); - } - } -}).array('attachments', 10); // up to 10 files +const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (isAllowedFileExtension(file.originalname)) cb(null, true); else cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`)); } }).array('attachments', 10); // --------------------------------------------------------------------------- // Router factory // --------------------------------------------------------------------------- - -function createIvantiFpWorkflowRouter(db, requireAuth) { +function createIvantiFpWorkflowRouter() { const router = express.Router(); - /** - * GET /api/ivanti/fp-workflow/documents/search - * - * Searches the CVE document library for existing documents that can be - * attached to FP workflow submissions. - * - * @param {string} [req.query.q] - Optional search term matched against name, cve_id, vendor - * @returns {Array} 200 - Array of matching document records - * @returns {object} 500 - Database error - */ - router.get('/documents/search', requireAuth(db), (req, res) => { + // GET /documents/search + router.get('/documents/search', requireAuth(), async (req, res) => { const q = (req.query.q || '').trim(); - let sql, params; - - if (q) { - const like = `%${q}%`; - sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at - FROM documents - WHERE name LIKE ? OR cve_id LIKE ? OR vendor LIKE ? - ORDER BY uploaded_at DESC - LIMIT 50`; - params = [like, like, like]; - } else { - sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at - FROM documents - ORDER BY uploaded_at DESC - LIMIT 50`; - params = []; - } - - db.all(sql, params, (err, rows) => { - if (err) { - console.error('Error searching documents:', err); - return res.status(500).json({ error: 'Database error.' }); + try { + let rows; + if (q) { + const like = `%${q}%`; + const result = await pool.query( + `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at FROM documents WHERE name ILIKE $1 OR cve_id ILIKE $2 OR vendor ILIKE $3 ORDER BY uploaded_at DESC LIMIT 50`, + [like, like, like] + ); + rows = result.rows; + } else { + const result = await pool.query(`SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at FROM documents ORDER BY uploaded_at DESC LIMIT 50`); + rows = result.rows; } res.json(rows || []); - }); + } catch (err) { + console.error('Error searching documents:', err); + res.status(500).json({ error: 'Database error.' }); + } }); - /** - * POST /api/ivanti/fp-workflow - * - * Creates a False Positive workflow batch in the Ivanti/RiskSense API, - * optionally uploads file attachments, records the submission locally, - * and marks the associated queue items as complete. - * - * Content-Type: multipart/form-data - * - * @param {string} req.body.name - Workflow name (required, max 255 chars) - * @param {string} req.body.reason - Reason for the FP determination (required) - * @param {string} [req.body.description] - Additional description (optional, max 2000 chars) - * @param {string} req.body.expirationDate - ISO date string, must be a future date (required) - * @param {string} [req.body.scopeOverride] - "Authorized" (default) or "None" - * @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs - * @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs - * @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each); - * allowed extensions: .pdf .png .jpg .jpeg .gif - * .doc .docx .xlsx .csv .txt .zip - * - * @returns {object} 200 - Success - * { success: true, workflowBatchId: number, generatedId: string, - * attachmentResults: Array<{ filename: string, success: boolean, error?: string }>, - * queueItemsUpdated: number, status: 'success' | 'partial' } - * @returns {object} 400 - Validation error - * { error: string } or { success: false, errors: { [field]: string } } - * @returns {object} 403 - Queue item ownership violation - * { error: string } - * @returns {object} 429 - Ivanti rate limit - * { success: false, error: string, step: 'create_workflow' } - * @returns {object} 500 - Server configuration error - * { success: false, error: string, step: 'create_workflow' } - * @returns {object} 502 - Ivanti API error - * { success: false, error: string, step: 'create_workflow', details?: string } - */ - router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST / — Create FP workflow + router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { fpUpload(req, res, (multerErr) => { if (multerErr) { - if (multerErr.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' }); - } + if (multerErr.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' }); return res.status(400).json({ error: multerErr.message }); } - // --- Parse JSON-encoded arrays from the multipart body --- let findingIds, queueItemIds; - try { - findingIds = JSON.parse(req.body.findingIds || '[]'); - queueItemIds = JSON.parse(req.body.queueItemIds || '[]'); - } catch (e) { - return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' }); - } + try { findingIds = JSON.parse(req.body.findingIds || '[]'); queueItemIds = JSON.parse(req.body.queueItemIds || '[]'); } + catch (e) { return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' }); } - if (!Array.isArray(findingIds) || findingIds.length === 0) { - return res.status(400).json({ error: 'At least one finding ID is required.' }); - } - if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) { - return res.status(400).json({ error: 'At least one queue item ID is required.' }); - } + if (!Array.isArray(findingIds) || findingIds.length === 0) return res.status(400).json({ error: 'At least one finding ID is required.' }); + if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) return res.status(400).json({ error: 'At least one queue item ID is required.' }); - // --- Parse and validate libraryDocIds --- let libraryDocIds; - try { - libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); - } catch (e) { - return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); - } - if (!Array.isArray(libraryDocIds)) { - return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); - } - // Validate each ID is a positive integer - for (const id of libraryDocIds) { - if (!Number.isInteger(id) || id <= 0) { - return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` }); - } - } + try { libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); } catch (e) { return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); } + if (!Array.isArray(libraryDocIds)) return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); + for (const id of libraryDocIds) { if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: `Invalid library document ID: ${id}.` }); } - // --- Validate form fields --- const validationErrors = validateFpWorkflowForm(req.body); - if (Object.keys(validationErrors).length > 0) { - return res.status(400).json({ success: false, errors: validationErrors }); - } + if (Object.keys(validationErrors).length > 0) return res.status(400).json({ success: false, errors: validationErrors }); - // --- Validate file extensions (belt-and-suspenders with Multer filter) --- const files = req.files || []; - for (const file of files) { - if (!isAllowedFileExtension(file.originalname)) { - return res.status(400).json({ - error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` - }); + for (const file of files) { if (!isAllowedFileExtension(file.originalname)) return res.status(400).json({ error: `File type not allowed: ${file.originalname}` }); } + + (async () => { + // Verify queue items + const { rows: queueRows } = await pool.query( + `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id = ANY($1)`, [queueItemIds] + ); + if (!queueRows || queueRows.length !== queueItemIds.length) return res.status(400).json({ error: 'One or more queue items not found.' }); + for (const row of queueRows) { + if (row.user_id !== req.user.id) return res.status(403).json({ error: 'You can only submit your own queue items.' }); + if (row.workflow_type !== 'FP') return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` }); + if (row.status !== 'pending') return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` }); } - } - // --- Verify queue items belong to user, are FP type, and pending --- - const placeholders = queueItemIds.map(() => '?').join(','); - db.all( - `SELECT id, workflow_type, status, user_id - FROM ivanti_todo_queue - WHERE id IN (${placeholders})`, - queueItemIds, - (err, rows) => { - if (err) { - console.error('Error verifying queue items:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + if (!apiKey) return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' }); - // Check all items were found - if (!rows || rows.length !== queueItemIds.length) { - return res.status(400).json({ error: 'One or more queue items not found.' }); - } + const formFields = buildIvantiFormFields(req.body, findingIds); - // Check ownership, type, and status - for (const row of rows) { - if (row.user_id !== req.user.id) { - return res.status(403).json({ error: 'You can only submit your own queue items.' }); - } - if (row.workflow_type !== 'FP') { - return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` }); - } - if (row.status !== 'pending') { - return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` }); - } - } - - // --- Validation passed — submit to Ivanti API --- - (async () => { - const apiKey = process.env.IVANTI_API_KEY; - const clientId = process.env.IVANTI_CLIENT_ID || '1550'; - const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; - - if (!apiKey) { - return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' }); - } - - // 1. Build form fields and call Ivanti API (multipart/form-data) - const formFields = buildIvantiFormFields(req.body, findingIds); - - // --- Look up library documents and read from disk --- - let libraryDocs = []; - const libraryAttachmentResults = []; - if (libraryDocIds.length > 0) { - const docPlaceholders = libraryDocIds.map(() => '?').join(','); - libraryDocs = await new Promise((resolve, reject) => { - db.all( - `SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`, - libraryDocIds, - (err, rows) => { if (err) reject(err); else resolve(rows || []); } - ); - }); - - // Validate all IDs were found - const foundIds = new Set(libraryDocs.map(d => d.id)); - const missingIds = libraryDocIds.filter(id => !foundIds.has(id)); - if (missingIds.length > 0) { - return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` }); - } - } - - // Build library file buffers (read from disk) - const libraryFormFiles = []; - for (const doc of libraryDocs) { - try { - const buffer = fs.readFileSync(doc.file_path); - libraryFormFiles.push({ name: 'files', buffer, filename: doc.name }); - libraryAttachmentResults.push({ filename: doc.name, success: true, source: 'library', documentId: doc.id }); - } catch (readErr) { - console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`); - libraryAttachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); - } - } - - // Combine local file buffers and library file buffers - const localFormFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname })); - const formFiles = [...localFormFiles, ...libraryFormFiles]; - const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`; - - let createResult; - try { - createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls); - } catch (networkErr) { - logAudit(db, { - userId: req.user.id, username: req.user.username, - action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', - details: { error: networkErr.message, findingIds }, - ipAddress: req.ip - }); - return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message }); - } - - // Handle error responses from Ivanti - if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) { - const errorMap = { - 401: 'Ivanti API key is invalid or missing.', - 419: 'API key lacks workflow creation permissions.', - 429: 'Ivanti API rate limit reached. Please try again in a few minutes.' - }; - const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`; - const errorResponse = { success: false, error: errorMsg, step: 'create_workflow' }; - if (!errorMap[createResult.status]) { - errorResponse.details = createResult.body; - } - - logAudit(db, { - userId: req.user.id, username: req.user.username, - action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', - details: { error: errorMsg, status: createResult.status, findingIds }, - ipAddress: req.ip - }); - - return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse); - } - - // 2. Parse workflow batch response — API returns { id, created } - let workflowBatchId; - try { - const createData = JSON.parse(createResult.body); - workflowBatchId = createData.id; - } catch (parseErr) { - logAudit(db, { - userId: req.user.id, username: req.user.username, - action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', - details: { error: 'Failed to parse Ivanti response', responseBody: createResult.body }, - ipAddress: req.ip - }); - return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' }); - } - - // 3. Determine submission status (files sent inline, so success if we got here) - const status = 'success'; - - // 3.5. Try to resolve the UUID for future map/attach operations - let workflowBatchUuid = null; - try { - workflowBatchUuid = await resolveWorkflowBatchUuid(db, { id: null, ivanti_workflow_batch_id: workflowBatchId, ivanti_workflow_batch_uuid: null }, apiKey, clientId, skipTls); - } catch (e) { - console.error('Failed to resolve workflow batch UUID after creation:', e); - } - - // 4. Insert submission record - const localAttachmentResults = files.map(f => ({ filename: f.originalname, success: true, source: 'local' })); - const allAttachmentResults = [...localAttachmentResults, ...libraryAttachmentResults]; - const totalAttachmentCount = files.length + libraryDocIds.length; - try { - await new Promise((resolve, reject) => { - db.run( - `INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_workflow_batch_uuid, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - req.user.id, - req.user.username, - workflowBatchId, - workflowBatchUuid, - null, // generatedId not returned by this endpoint - req.body.name, - req.body.reason, - req.body.description || null, - req.body.expirationDate, - req.body.scopeOverride || 'Authorized', - JSON.stringify(findingIds), - JSON.stringify(queueItemIds), - totalAttachmentCount, - JSON.stringify(allAttachmentResults), - status, - null - ], - (err) => { if (err) reject(err); else resolve(); } - ); - }); - } catch (dbErr) { - console.error('Failed to insert submission record:', dbErr); - // Don't fail the response — the Ivanti workflow was created - } - - // 5. Log audit entry - logAudit(db, { - userId: req.user.id, username: req.user.username, - action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow', - entityId: String(workflowBatchId), - details: { workflowName: req.body.name, findingIds, attachmentCount: totalAttachmentCount, libraryDocCount: libraryDocIds.length, status }, - ipAddress: req.ip - }); - - // 6. Mark queue items as complete - let queueItemsUpdated = 0; - try { - const queuePlaceholders = queueItemIds.map(() => '?').join(','); - queueItemsUpdated = await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`, - [...queueItemIds, req.user.id], - function (err) { if (err) reject(err); else resolve(this.changes); } - ); - }); - } catch (queueErr) { - console.error('Failed to update queue items:', queueErr); - // Don't fail — workflow was created - } - - // 7. Return response - res.json({ - success: true, - workflowBatchId, - queueItemsUpdated, - status - }); - })().catch((unexpectedErr) => { - console.error('Unexpected error in FP workflow submission:', unexpectedErr); - res.status(500).json({ success: false, error: 'Internal server error.' }); - }); + // Look up library documents + let libraryDocs = []; + const libraryAttachmentResults = []; + if (libraryDocIds.length > 0) { + const { rows: docRows } = await pool.query(`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id = ANY($1)`, [libraryDocIds]); + libraryDocs = docRows; + const foundIds = new Set(libraryDocs.map(d => d.id)); + const missingIds = libraryDocIds.filter(id => !foundIds.has(id)); + if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` }); } - ); - }); - }); - /** - * GET /api/ivanti/fp-submissions - * - * Returns the authenticated user's FP submission records, each enriched - * with its submission history entries. - * - * @returns {Array} 200 - Array of FP submission records, each with: - * { id, user_id, username, ivanti_workflow_batch_id, ivanti_workflow_batch_uuid, - * workflow_name, reason, description, expiration_date, scope_override, - * finding_ids_json, queue_item_ids_json, attachment_count, - * attachment_results_json, status, lifecycle_status, error_message, - * created_at, updated_at, history: Array } - * @returns {object} 500 - Internal server error - * { error: string } - */ - router.get('/submissions', requireAuth(db), (req, res) => { - (async () => { - try { - const submissions = await new Promise((resolve, reject) => { - db.all( - `SELECT * FROM ivanti_fp_submissions WHERE user_id = ? ORDER BY created_at DESC`, - [req.user.id], - (err, rows) => { if (err) reject(err); else resolve(rows || []); } + const libraryFormFiles = []; + for (const doc of libraryDocs) { + try { const buffer = fs.readFileSync(doc.file_path); libraryFormFiles.push({ name: 'files', buffer, filename: doc.name }); libraryAttachmentResults.push({ filename: doc.name, success: true, source: 'library', documentId: doc.id }); } + catch (readErr) { libraryAttachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); } + } + + const localFormFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname })); + const formFiles = [...localFormFiles, ...libraryFormFiles]; + const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`; + + let createResult; + try { createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls); } + catch (networkErr) { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: networkErr.message, findingIds }, ipAddress: req.ip }); + return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message }); + } + + if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) { + const errorMap = { 401: 'Ivanti API key is invalid or missing.', 419: 'API key lacks workflow creation permissions.', 429: 'Ivanti API rate limit reached.' }; + const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`; + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, findingIds }, ipAddress: req.ip }); + return res.status(createResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg, step: 'create_workflow' }); + } + + let workflowBatchId; + try { const createData = JSON.parse(createResult.body); workflowBatchId = createData.id; } + catch (parseErr) { return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' }); } + + let workflowBatchUuid = null; + try { workflowBatchUuid = await resolveWorkflowBatchUuid({ id: null, ivanti_workflow_batch_id: workflowBatchId, ivanti_workflow_batch_uuid: null, workflow_name: req.body.name }, apiKey, clientId, skipTls); } catch (e) {} + + const localAttachmentResults = files.map(f => ({ filename: f.originalname, success: true, source: 'local' })); + const allAttachmentResults = [...localAttachmentResults, ...libraryAttachmentResults]; + const totalAttachmentCount = files.length + libraryDocIds.length; + + try { + await pool.query( + `INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_workflow_batch_uuid, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + [req.user.id, req.user.username, workflowBatchId, workflowBatchUuid, null, req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', JSON.stringify(findingIds), JSON.stringify(queueItemIds), totalAttachmentCount, JSON.stringify(allAttachmentResults), 'success', null] ); - }); + } catch (dbErr) { console.error('Failed to insert submission record:', dbErr); } - // Fetch history for all submissions in one query if there are any - if (submissions.length > 0) { - const submissionIds = submissions.map(s => s.id); - const placeholders = submissionIds.map(() => '?').join(','); - const historyRows = await new Promise((resolve, reject) => { - db.all( - `SELECT * FROM ivanti_fp_submission_history WHERE submission_id IN (${placeholders}) ORDER BY created_at ASC`, - submissionIds, - (err, rows) => { if (err) reject(err); else resolve(rows || []); } - ); - }); + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow', entityId: String(workflowBatchId), details: { workflowName: req.body.name, findingIds, attachmentCount: totalAttachmentCount, status: 'success' }, ipAddress: req.ip }); - // Group history by submission_id - const historyMap = {}; - for (const row of historyRows) { - if (!historyMap[row.submission_id]) historyMap[row.submission_id] = []; - historyMap[row.submission_id].push(row); - } + let queueItemsUpdated = 0; + try { + const qResult = await pool.query(`UPDATE ivanti_todo_queue SET status='complete', updated_at=NOW() WHERE id = ANY($1) AND user_id=$2`, [queueItemIds, req.user.id]); + queueItemsUpdated = qResult.rowCount; + } catch (queueErr) { console.error('Failed to update queue items:', queueErr); } - for (const sub of submissions) { - sub.history = historyMap[sub.id] || []; - } - - // Enrich submissions with Ivanti reviewer notes (rework/approval feedback) - const apiKey = process.env.IVANTI_API_KEY; - const clientId = process.env.IVANTI_CLIENT_ID || '1550'; - const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; - - if (apiKey) { - try { - // Batch search by all workflow names - for (const sub of submissions) { - if (!sub.workflow_name) continue; - const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; - const searchBody = { - filters: [{ field: 'name', exclusive: false, operator: 'EXACT', value: sub.workflow_name }], - projection: 'internal', - sort: [{ field: 'created', direction: 'DESC' }], - page: 0, size: 1 - }; - const result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); - if (result.status === 200) { - const data = JSON.parse(result.body); - const batches = data._embedded?.workflowBatches || data._embedded?.workflowBatch || []; - const batch = batches[0]; - if (batch) { - sub.ivanti_rework_note = batch.reworkNote || null; - sub.ivanti_approval_note = batch.approvalNote || null; - sub.ivanti_current_state_notes = batch.currentStateUserNotes || null; - sub.ivanti_previous_state_notes = batch.previousStateUserNotes || null; - sub.ivanti_current_state = batch.currentState || null; - } - } - } - } catch (e) { - console.error('Error enriching submissions with Ivanti notes:', e.message); - // Don't fail the response — notes are supplementary - } - } - } else { - // No submissions, nothing to do - } - - res.json(submissions); - } catch (err) { - console.error('Error fetching FP submissions:', err); - res.status(500).json({ error: 'Internal server error.' }); - } - })().catch((unexpectedErr) => { - console.error('Unexpected error in GET /submissions:', unexpectedErr); - res.status(500).json({ error: 'Internal server error.' }); + res.json({ success: true, workflowBatchId, queueItemsUpdated, status: 'success' }); + })().catch((unexpectedErr) => { console.error('Unexpected error in FP workflow submission:', unexpectedErr); res.status(500).json({ success: false, error: 'Internal server error.' }); }); }); }); - /** - * PUT /api/ivanti/fp-submissions/:id - * - * Updates form fields of an existing FP submission and proxies the - * changes to the Ivanti update endpoint. Records the edit in - * submission history and audit log. Automatically transitions - * lifecycle_status to "resubmitted" when editing a rejected/rework - * submission. - * - * @param {string} req.params.id - Local FP submission ID - * @param {string} req.body.name - Workflow name (required, max 255 chars) - * @param {string} req.body.reason - Reason for the FP determination (required) - * @param {string} [req.body.description] - Additional description (optional, max 2000 chars) - * @param {string} req.body.expirationDate - ISO date string, must be a future date (required) - * @param {string} [req.body.scopeOverride] - "Authorized" (default), "None", or "Automated" - * - * @returns {object} 200 - Success - * { success: true, submission: object } - * @returns {object} 400 - Validation error or lifecycle guard - * { success: false, errors: { [field]: string } } or { error: string } - * @returns {object} 403 - Ownership violation - * { error: string } - * @returns {object} 404 - Submission not found - * { error: string } - * @returns {object} 429 - Ivanti rate limit - * { success: false, error: string } - * @returns {object} 500 - Server/database error - * { success: false, error: string } - * @returns {object} 502 - Ivanti API error - * { success: false, error: string, details?: string } - */ - router.put('/submissions/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // GET /submissions + router.get('/submissions', requireAuth(), async (req, res) => { + try { + const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]); + + if (submissions.length > 0) { + const submissionIds = submissions.map(s => s.id); + const { rows: historyRows } = await pool.query(`SELECT * FROM ivanti_fp_submission_history WHERE submission_id = ANY($1) ORDER BY created_at ASC`, [submissionIds]); + const historyMap = {}; + for (const row of historyRows) { if (!historyMap[row.submission_id]) historyMap[row.submission_id] = []; historyMap[row.submission_id].push(row); } + for (const sub of submissions) { sub.history = historyMap[sub.id] || []; } + + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + if (apiKey) { + try { + for (const sub of submissions) { + if (!sub.workflow_name) continue; + const searchUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`; + const searchBody = { filters: [{ field: 'name', exclusive: false, operator: 'EXACT', value: sub.workflow_name }], projection: 'internal', sort: [{ field: 'created', direction: 'DESC' }], page: 0, size: 1 }; + const result = await ivantiPost(searchUrl, searchBody, apiKey, skipTls); + if (result.status === 200) { + const data = JSON.parse(result.body); + const batches = data._embedded?.workflowBatches || data._embedded?.workflowBatch || []; + const batch = batches[0]; + if (batch) { sub.ivanti_rework_note = batch.reworkNote || null; sub.ivanti_approval_note = batch.approvalNote || null; sub.ivanti_current_state_notes = batch.currentStateUserNotes || null; sub.ivanti_previous_state_notes = batch.previousStateUserNotes || null; sub.ivanti_current_state = batch.currentState || null; } + } + } + } catch (e) { console.error('Error enriching submissions with Ivanti notes:', e.message); } + } + } + res.json(submissions); + } catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); } + }); + + // PUT /submissions/:id — Edit FP workflow fields + router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; // 1. Fetch submission and verify ownership - const submission = await new Promise((resolve, reject) => { - db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; if (!submission) { return res.status(404).json({ error: 'Submission not found.' }); @@ -790,7 +353,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { try { ivantiResult = await ivantiPost(updateUrl, updateBody, apiKey, skipTls); } catch (networkErr) { - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_submission_edit_failed', entityType: 'ivanti_workflow', details: { error: networkErr.message, submissionId }, @@ -818,26 +381,13 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { } // 6. Update local record - const now = new Date().toISOString(); try { - await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_fp_submissions - SET workflow_name = ?, reason = ?, description = ?, expiration_date = ?, scope_override = ?, lifecycle_status = ?, updated_at = ? - WHERE id = ?`, - [ - req.body.name, - req.body.reason, - req.body.description || null, - req.body.expirationDate, - req.body.scopeOverride || 'Authorized', - newLifecycleStatus, - now, - submissionId - ], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `UPDATE ivanti_fp_submissions + SET workflow_name = $1, reason = $2, description = $3, expiration_date = $4, scope_override = $5, lifecycle_status = $6, updated_at = NOW() + WHERE id = $7`, + [req.body.name, req.body.reason, req.body.description || null, req.body.expirationDate, req.body.scopeOverride || 'Authorized', newLifecycleStatus, submissionId] + ); } catch (dbErr) { console.error('Failed to update submission record:', dbErr); return res.status(500).json({ success: false, error: 'Failed to update local record.' }); @@ -855,20 +405,17 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }, req.user.id, req.user.username); try { - await new Promise((resolve, reject) => { - db.run( - `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES ($1, $2, $3, $4, $5, NOW())`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json] + ); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 8. Audit log - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_submission_edited', entityType: 'ivanti_workflow', entityId: String(submission.ivanti_workflow_batch_id), @@ -877,52 +424,23 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); // 9. Return updated record - const updatedRecord = await new Promise((resolve, reject) => { - db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); + const { rows: updatedRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); - res.json({ success: true, submission: updatedRecord }); + res.json({ success: true, submission: updatedRows[0] }); })().catch((unexpectedErr) => { console.error('Unexpected error in PUT /submissions/:id:', unexpectedErr); res.status(500).json({ success: false, error: 'Internal server error.' }); }); }); - /** - * POST /api/ivanti/fp-submissions/:id/findings - * - * Maps additional findings to an existing FP workflow batch via the - * Ivanti map endpoint. Merges the new finding IDs into the local - * submission record, marks the corresponding queue items as complete, - * and records the change in submission history and audit log. - * - * @param {string} req.params.id - Local FP submission ID - * @param {string[]} req.body.findingIds - Array of Ivanti finding IDs to add - * @param {number[]} req.body.queueItemIds - Array of local queue item IDs (must be FP, pending, owned by user) - * - * @returns {object} 200 - Success - * { success: true, addedFindings: string[], queueItemsUpdated: number } - * @returns {object} 400 - Validation error, lifecycle guard, or UUID resolution failure - * { error: string } or { success: false, error: string } - * @returns {object} 403 - Ownership violation - * { error: string } - * @returns {object} 404 - Submission not found - * { error: string } - * @returns {object} 429 - Ivanti rate limit - * { success: false, error: string } - * @returns {object} 500 - Server error - * { success: false, error: string } - * @returns {object} 502 - Ivanti API error - * { success: false, error: string, details?: string } - */ - router.post('/submissions/:id/findings', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST /submissions/:id/findings — Map additional findings to existing workflow + router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; const { findingIds, queueItemIds } = req.body; - // Validate arrays if (!Array.isArray(findingIds) || findingIds.length === 0) { return res.status(400).json({ error: 'At least one finding ID is required.' }); } @@ -931,11 +449,10 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { } // 1. Fetch submission and verify ownership - const submission = await new Promise((resolve, reject) => { - db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; if (!submission) { return res.status(404).json({ error: 'Submission not found.' }); @@ -950,14 +467,10 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { } // 3. Verify queue items belong to user, are FP type, and pending - const placeholders = queueItemIds.map(() => '?').join(','); - const queueRows = await new Promise((resolve, reject) => { - db.all( - `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`, - queueItemIds, - (err, rows) => { if (err) reject(err); else resolve(rows || []); } - ); - }); + const { rows: queueRows } = await pool.query( + `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id = ANY($1)`, + [queueItemIds] + ); if (queueRows.length !== queueItemIds.length) { return res.status(400).json({ error: 'One or more queue items not found.' }); @@ -983,26 +496,20 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); } - const mapUuid = await resolveWorkflowBatchUuid(db, submission, apiKey, clientId, skipTls); + const mapUuid = await resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls); if (!mapUuid) { return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID. The workflow may not exist in Ivanti.' }); } const mapUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(mapUuid)}/map`; - // Map each finding individually via JSON POST (Ivanti map endpoint only accepts one finding per call) const mappedIds = []; const failedIds = []; for (const fid of findingIds) { const mapBody = { subject: 'hostFinding', filterRequest: { - filters: [{ - field: 'id', - exclusive: false, - operator: 'EXACT', - value: String(fid) - }] + filters: [{ field: 'id', exclusive: false, operator: 'EXACT', value: String(fid) }] } }; try { @@ -1023,23 +530,18 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { // 5. Merge only successfully mapped finding IDs const mergedJson = mergeFindings(submission.finding_ids_json, mappedIds); - const now = new Date().toISOString(); try { - await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_fp_submissions SET finding_ids_json = ?, updated_at = ? WHERE id = ?`, - [mergedJson, now, submissionId], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `UPDATE ivanti_fp_submissions SET finding_ids_json = $1, updated_at = NOW() WHERE id = $2`, + [mergedJson, submissionId] + ); } catch (dbErr) { console.error('Failed to update finding_ids_json:', dbErr); } // 6. Mark only successfully mapped queue items as complete let queueItemsUpdated = 0; - // Build a set of successfully mapped finding IDs to match against queue items const mappedSet = new Set(mappedIds.map(String)); const successQueueIds = queueItemIds.filter((qid, idx) => { const queueItem = queueRows.find(r => r.id === qid); @@ -1047,14 +549,11 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); if (successQueueIds.length > 0) { try { - const queuePlaceholders = successQueueIds.map(() => '?').join(','); - queueItemsUpdated = await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`, - [...successQueueIds, req.user.id], - function (err) { if (err) reject(err); else resolve(this.changes); } - ); - }); + const qResult = await pool.query( + `UPDATE ivanti_todo_queue SET status='complete', updated_at=NOW() WHERE id = ANY($1) AND user_id=$2`, + [successQueueIds, req.user.id] + ); + queueItemsUpdated = qResult.rowCount; } catch (queueErr) { console.error('Failed to update queue items:', queueErr); } @@ -1068,20 +567,17 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }, req.user.id, req.user.username); try { - await new Promise((resolve, reject) => { - db.run( - `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES ($1, $2, $3, $4, $5, NOW())`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json] + ); } catch (histErr) { console.error('Failed to insert history row:', histErr); } // 8. Audit log - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_findings_added', entityType: 'ivanti_workflow', entityId: String(submission.ivanti_workflow_batch_id), @@ -1096,40 +592,8 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - /** - * POST /api/ivanti/fp-submissions/:id/attachments - * - * Uploads additional file attachments to an existing FP workflow batch - * via the Ivanti attach endpoint. Supports both local file uploads and - * library document references. Updates the local submission record's - * attachment_count and attachment_results_json, and records the change - * in submission history and audit log. - * - * Content-Type: multipart/form-data - * - * @param {string} req.params.id - Local FP submission ID - * @param {File[]} req.files - Zero or more file attachments (field name "attachments", - * max 10 files, max 10 MB each); allowed extensions: - * .pdf .png .jpg .jpeg .gif .doc .docx .xlsx .csv .txt .zip - * @param {string} [req.body.libraryDocIds] - JSON-encoded array of document IDs from the - * documents table to attach from the library (optional) - * - * At least one local file or library document ID is required. - * - * @returns {object} 200 - Success (or partial success) - * { success: true, - * attachmentResults: Array<{ filename: string, success: boolean, source: string, documentId?: number, error?: string }>, - * status: 'success' | 'partial' } - * @returns {object} 400 - Validation error, lifecycle guard, file constraint, or UUID resolution failure - * { error: string } or { success: false, error: string } - * @returns {object} 403 - Ownership violation - * { error: string } - * @returns {object} 404 - Submission not found - * { error: string } - * @returns {object} 500 - Server error - * { success: false, error: string } - */ - router.post('/submissions/:id/attachments', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST /submissions/:id/attachments — Upload additional attachments + router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { fpUpload(req, res, (multerErr) => { if (multerErr) { if (multerErr.code === 'LIMIT_FILE_SIZE') { @@ -1140,93 +604,54 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { const files = req.files || []; - // --- Parse and validate libraryDocIds --- let libraryDocIds; - try { - libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); - } catch (e) { - return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); - } - if (!Array.isArray(libraryDocIds)) { - return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); - } - // Validate each ID is a positive integer - for (const id of libraryDocIds) { - if (!Number.isInteger(id) || id <= 0) { - return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` }); - } - } + try { libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]'); } + catch (e) { return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); } + if (!Array.isArray(libraryDocIds)) return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' }); + for (const id of libraryDocIds) { if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: `Invalid library document ID: ${id}.` }); } - // Require at least one file (local or library) if (files.length === 0 && libraryDocIds.length === 0) { return res.status(400).json({ error: 'At least one file or library document is required.' }); } - // Validate extensions (belt-and-suspenders) for (const file of files) { if (!isAllowedFileExtension(file.originalname)) { - return res.status(400).json({ - error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` - }); + return res.status(400).json({ error: `File type not allowed: ${file.originalname}` }); } } (async () => { const submissionId = req.params.id; - // 1. Fetch submission and verify ownership - const submission = await new Promise((resolve, reject) => { - db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; - if (!submission) { - return res.status(404).json({ error: 'Submission not found.' }); - } - if (submission.user_id !== req.user.id) { - return res.status(403).json({ error: 'You can only edit your own submissions.' }); - } + if (!submission) return res.status(404).json({ error: 'Submission not found.' }); + if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only edit your own submissions.' }); + if (submission.lifecycle_status === 'approved') return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' }); - // 2. Lifecycle guard - if (submission.lifecycle_status === 'approved') { - return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' }); - } - - // 2.5. Look up library documents and read from disk + // Look up library documents let libraryDocs = []; - const libraryAttachmentResults = []; if (libraryDocIds.length > 0) { - const docPlaceholders = libraryDocIds.map(() => '?').join(','); - libraryDocs = await new Promise((resolve, reject) => { - db.all( - `SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`, - libraryDocIds, - (err, rows) => { if (err) reject(err); else resolve(rows || []); } - ); - }); - - // Validate all IDs were found + const { rows: docRows } = await pool.query( + `SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id = ANY($1)`, [libraryDocIds] + ); + libraryDocs = docRows; const foundIds = new Set(libraryDocs.map(d => d.id)); const missingIds = libraryDocIds.filter(id => !foundIds.has(id)); - if (missingIds.length > 0) { - return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` }); - } + if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` }); } - // 3. Upload each file to Ivanti const apiKey = process.env.IVANTI_API_KEY; const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; - if (!apiKey) { - return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); - } + if (!apiKey) return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); - const attachUuid = await resolveWorkflowBatchUuid(db, submission, apiKey, clientId, skipTls); - if (!attachUuid) { - return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID. The workflow may not exist in Ivanti.' }); - } + const attachUuid = await resolveWorkflowBatchUuid(submission, apiKey, clientId, skipTls); + if (!attachUuid) return res.status(400).json({ success: false, error: 'Could not resolve workflow batch UUID.' }); const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`; const attachmentResults = []; @@ -1246,13 +671,8 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { // Upload library files for (const doc of libraryDocs) { let buffer; - try { - buffer = fs.readFileSync(doc.file_path); - } catch (readErr) { - console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`); - attachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); - continue; - } + try { buffer = fs.readFileSync(doc.file_path); } + catch (readErr) { attachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name }); continue; } try { const formFiles = [{ name: 'file', buffer, filename: doc.name, contentType: doc.mime_type || 'application/octet-stream' }]; const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls); @@ -1263,45 +683,30 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { } } - // 4. Update attachment_count and attachment_results_json + // Update attachment_count and attachment_results_json const existingResults = JSON.parse(submission.attachment_results_json || '[]'); const allResults = [...existingResults, ...attachmentResults]; const successCount = attachmentResults.filter(r => r.success).length; const newAttachmentCount = (submission.attachment_count || 0) + successCount; - const now = new Date().toISOString(); try { - await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_fp_submissions SET attachment_count = ?, attachment_results_json = ?, updated_at = ? WHERE id = ?`, - [newAttachmentCount, JSON.stringify(allResults), now, submissionId], - (err) => { if (err) reject(err); else resolve(); } - ); - }); - } catch (dbErr) { - console.error('Failed to update attachment records:', dbErr); - } - - // 5. Insert history row - const historyEntry = buildSubmissionHistoryEntry('attachments_added', { - files: attachmentResults - }, req.user.id, req.user.username); + await pool.query( + `UPDATE ivanti_fp_submissions SET attachment_count = $1, attachment_results_json = $2, updated_at = NOW() WHERE id = $3`, + [newAttachmentCount, JSON.stringify(allResults), submissionId] + ); + } catch (dbErr) { console.error('Failed to update attachment records:', dbErr); } + // Insert history row + const historyEntry = buildSubmissionHistoryEntry('attachments_added', { files: attachmentResults }, req.user.id, req.user.username); try { - await new Promise((resolve, reject) => { - db.run( - `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], - (err) => { if (err) reject(err); else resolve(); } - ); - }); - } catch (histErr) { - console.error('Failed to insert history row:', histErr); - } + await pool.query( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES ($1, $2, $3, $4, $5, NOW())`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json] + ); + } catch (histErr) { console.error('Failed to insert history row:', histErr); } - // 6. Audit log - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow', entityId: String(submission.ivanti_workflow_batch_id), @@ -1318,92 +723,45 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); - /** - * PATCH /api/ivanti/fp-submissions/:id/status - * - * Updates the lifecycle status of an FP submission. Validates the - * transition (no transitions allowed from "approved"), records the - * change in submission history and audit log. - * - * @param {string} req.params.id - Local FP submission ID - * @param {string} req.body.lifecycle_status - New lifecycle status; one of: - * "submitted", "approved", "rejected", - * "rework", "resubmitted" - * - * @returns {object} 200 - Success - * { success: true, previousStatus: string, newStatus: string } - * @returns {object} 400 - Invalid transition or invalid status value - * { error: string } - * @returns {object} 403 - Ownership violation - * { error: string } - * @returns {object} 404 - Submission not found - * { error: string } - * @returns {object} 500 - Server/database error - * { success: false, error: string } - */ - router.patch('/submissions/:id/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // PATCH /submissions/:id/status — Update lifecycle status + router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; const newStatus = req.body.lifecycle_status; - // 1. Fetch submission and verify ownership - const submission = await new Promise((resolve, reject) => { - db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { - if (err) reject(err); else resolve(row); - }); - }); + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; - if (!submission) { - return res.status(404).json({ error: 'Submission not found.' }); - } - if (submission.user_id !== req.user.id) { - return res.status(403).json({ error: 'You can only edit your own submissions.' }); - } + if (!submission) return res.status(404).json({ error: 'Submission not found.' }); + if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only edit your own submissions.' }); - // 2. Validate transition const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus); - if (!transition.valid) { - return res.status(400).json({ error: transition.error }); - } + if (!transition.valid) return res.status(400).json({ error: transition.error }); - // 3. Update lifecycle_status and updated_at - const now = new Date().toISOString(); const previousStatus = submission.lifecycle_status; try { - await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_fp_submissions SET lifecycle_status = ?, updated_at = ? WHERE id = ?`, - [newStatus, now, submissionId], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `UPDATE ivanti_fp_submissions SET lifecycle_status = $1, updated_at = NOW() WHERE id = $2`, + [newStatus, submissionId] + ); } catch (dbErr) { console.error('Failed to update lifecycle status:', dbErr); return res.status(500).json({ success: false, error: 'Failed to update status.' }); } - // 4. Insert history row - const historyEntry = buildSubmissionHistoryEntry('status_changed', { - from: previousStatus, - to: newStatus - }, req.user.id, req.user.username); - + const historyEntry = buildSubmissionHistoryEntry('status_changed', { from: previousStatus, to: newStatus }, req.user.id, req.user.username); try { - await new Promise((resolve, reject) => { - db.run( - `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], - (err) => { if (err) reject(err); else resolve(); } - ); - }); - } catch (histErr) { - console.error('Failed to insert history row:', histErr); - } + await pool.query( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES ($1, $2, $3, $4, $5, NOW())`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json] + ); + } catch (histErr) { console.error('Failed to insert history row:', histErr); } - // 5. Audit log - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_status_changed', entityType: 'ivanti_workflow', entityId: String(submission.ivanti_workflow_batch_id), diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index d8aa977..9531365 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -1,6 +1,7 @@ // routes/ivantiTodoQueue.js const express = require('express'); -const { requireGroup } = require('../middleware/auth'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE']; @@ -12,71 +13,34 @@ function isValidVendor(vendor) { return trimmed.length > 0 && trimmed.length <= 200; } -function createIvantiTodoQueueRouter(db, requireAuth) { +function createIvantiTodoQueueRouter() { const router = express.Router(); - /** - * GET /api/ivanti/todo-queue - * - * Fetch the current user's queue items, ordered by vendor then created_at. - * - * @returns {Array} 200 - Array of queue items, each with: - * id, user_id, finding_id, finding_title, cves_json, ip_address, - * vendor, workflow_type, status, created_at, updated_at, cves (parsed array) - * @returns {Object} 500 - { error: string } on database error - */ - router.get('/', requireAuth(db), (req, res) => { - db.all( - `SELECT q.*, - o.value AS override_hostname - FROM ivanti_todo_queue q - LEFT JOIN ivanti_finding_overrides o - ON o.finding_id = q.finding_id AND o.field = 'hostName' - WHERE q.user_id = ? - ORDER BY q.vendor ASC, q.created_at ASC`, - [req.user.id], - (err, rows) => { - if (err) { - console.error('Error fetching todo queue:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - // Parse cves_json back to array; prefer overridden hostname - const parsed = rows.map((r) => ({ - ...r, - hostname: r.override_hostname || r.hostname, - cves: r.cves_json ? JSON.parse(r.cves_json) : [], - })); - // Clean up the extra column from the response - parsed.forEach((r) => delete r.override_hostname); - res.json(parsed); - } - ); + // GET /api/ivanti/todo-queue + router.get('/', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT q.* + FROM ivanti_todo_queue q + WHERE q.user_id = $1 + ORDER BY q.vendor ASC, q.created_at ASC`, + [req.user.id] + ); + const parsed = rows.map((r) => ({ + ...r, + cves: r.cves_json ? JSON.parse(r.cves_json) : [], + })); + res.json(parsed); + } catch (err) { + console.error('Error fetching todo queue:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * POST /api/ivanti/todo-queue/batch - * - * Add multiple findings to the current user's queue in a single transaction. - * - * @body {Object[]} findings - Required array of 1–200 finding objects - * @body {string} findings[].finding_id - Required, non-empty finding identifier - * @body {string} [findings[].finding_title] - Optional finding title (max 500 chars) - * @body {string[]} [findings[].cves] - Optional array of CVE identifiers - * @body {string} [findings[].ip_address] - Optional IP address (max 64 chars) - * @body {string} [findings[].hostname] - Optional hostname (max 255 chars) - * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE' - * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE - * - * @returns {Object} 201 - { items: Array } array of created queue items, - * each with: id, user_id, finding_id, finding_title, cves_json, ip_address, - * vendor, workflow_type, status, created_at, updated_at, cves (parsed array) - * @returns {Object} 400 - { error: string } on validation failure - * @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back) - */ - router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST /api/ivanti/todo-queue/batch + router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { findings, workflow_type, vendor } = req.body; - // --- Validation --- if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { return res.status(400).json({ error: 'findings array must contain 1-200 items.' }); } @@ -105,131 +69,70 @@ function createIvantiTodoQueueRouter(db, requireAuth) { const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const userId = req.user.id; - // --- Transactional batch insert --- - // Prepare all row values upfront - const rows = findings.map((f) => { - const findingId = f.finding_id.trim(); - const title = f.finding_title && typeof f.finding_title === 'string' - ? f.finding_title.slice(0, 500) - : null; - const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null; - const ipVal = f.ip_address && typeof f.ip_address === 'string' - ? f.ip_address.trim().slice(0, 64) - : null; - const hostVal = f.hostname && typeof f.hostname === 'string' - ? f.hostname.trim().slice(0, 255) - : null; - return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]; - }); + const client = await pool.connect(); + try { + await client.query('BEGIN'); - const insertedIds = []; - let insertError = null; - let remaining = rows.length; + const insertedIds = []; + for (const f of findings) { + const findingId = f.finding_id.trim(); + const title = f.finding_title && typeof f.finding_title === 'string' + ? f.finding_title.slice(0, 500) : null; + const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null; + const ipVal = f.ip_address && typeof f.ip_address === 'string' + ? f.ip_address.trim().slice(0, 64) : null; + const hostVal = f.hostname && typeof f.hostname === 'string' + ? f.hostname.trim().slice(0, 255) : null; - db.serialize(() => { - db.run('BEGIN TRANSACTION'); - - rows.forEach((params) => { - db.run( + const { rows } = await client.query( `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - params, - function (err) { - if (err && !insertError) { - insertError = err; - } else if (!err) { - insertedIds.push(this.lastID); - } - remaining--; - - // After all insert callbacks have fired, commit or rollback - if (remaining === 0) { - if (insertError) { - db.run('ROLLBACK', () => { - console.error('Batch insert error:', insertError); - return res.status(500).json({ error: 'Internal server error.' }); - }); - } else { - db.run('COMMIT', (commitErr) => { - if (commitErr) { - console.error('Batch commit error:', commitErr); - db.run('ROLLBACK', () => {}); - return res.status(500).json({ error: 'Internal server error.' }); - } - - // Fetch all inserted rows - const placeholders = insertedIds.map(() => '?').join(','); - db.all( - `SELECT q.*, o.value AS override_hostname - FROM ivanti_todo_queue q - LEFT JOIN ivanti_finding_overrides o - ON o.finding_id = q.finding_id AND o.field = 'hostName' - WHERE q.id IN (${placeholders})`, - insertedIds, - (fetchErr, fetchedRows) => { - if (fetchErr) { - console.error('Error fetching inserted batch rows:', fetchErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - const items = (fetchedRows || []).map((r) => { - const item = { - ...r, - hostname: r.override_hostname || r.hostname, - cves: r.cves_json ? JSON.parse(r.cves_json) : [], - }; - delete item.override_hostname; - return item; - }); - - // Audit log (fire-and-forget) - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'batch_add_to_queue', - entityType: 'ivanti_todo_queue', - entityId: null, - details: { - count: insertedIds.length, - workflow_type: workflow_type, - finding_ids: findings.map((f) => f.finding_id.trim()), - }, - ipAddress: req.ip, - }); - - return res.status(201).json({ items }); - } - ); - }); - } - } - } + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type] ); + insertedIds.push(rows[0].id); + } + + await client.query('COMMIT'); + + // Fetch all inserted rows + const { rows: fetchedRows } = await pool.query( + `SELECT * FROM ivanti_todo_queue WHERE id = ANY($1)`, + [insertedIds] + ); + + const items = fetchedRows.map((r) => ({ + ...r, + cves: r.cves_json ? JSON.parse(r.cves_json) : [], + })); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'batch_add_to_queue', + entityType: 'ivanti_todo_queue', + entityId: null, + details: { + count: insertedIds.length, + workflow_type: workflow_type, + finding_ids: findings.map((f) => f.finding_id.trim()), + }, + ipAddress: req.ip, }); - }); + + return res.status(201).json({ items }); + } catch (err) { + await client.query('ROLLBACK'); + console.error('Batch insert error:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } finally { + client.release(); + } }); - /** - * POST /api/ivanti/todo-queue - * - * Add a single finding to the current user's queue. - * - * @body {string} finding_id - Required, non-empty finding identifier - * @body {string} [finding_title] - Optional finding title (max 500 chars) - * @body {string[]} [cves] - Optional array of CVE identifiers - * @body {string} [ip_address] - Optional IP address (max 64 chars) - * @body {string} [hostname] - Optional hostname (max 255 chars) - * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE' - * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE - * - * @returns {Object} 201 - Created queue item with parsed cves array: - * id, user_id, finding_id, finding_title, cves_json, ip_address, - * vendor, workflow_type, status, created_at, updated_at, cves - * @returns {Object} 400 - { error: string } on validation failure - * @returns {Object} 500 - { error: string } on database error - */ - router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST /api/ivanti/todo-queue + router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body; if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { @@ -238,7 +141,6 @@ function createIvantiTodoQueueRouter(db, requireAuth) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } - // Vendor is required for FP and Archer, optional for CARD/GRANITE if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } @@ -251,61 +153,30 @@ function createIvantiTodoQueueRouter(db, requireAuth) { const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null; const title = finding_title && typeof finding_title === 'string' - ? finding_title.slice(0, 500) - : null; + ? finding_title.slice(0, 500) : null; - db.run( - `INSERT INTO ivanti_todo_queue - (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type], - function (err) { - if (err) { - console.error('Error adding to queue:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - db.get( - `SELECT q.*, o.value AS override_hostname - FROM ivanti_todo_queue q - LEFT JOIN ivanti_finding_overrides o - ON o.finding_id = q.finding_id AND o.field = 'hostName' - WHERE q.id = ?`, - [this.lastID], - (err2, row) => { - if (err2 || !row) { - return res.status(201).json({ id: this.lastID, message: 'Added to queue.' }); - } - const result = { - ...row, - hostname: row.override_hostname || row.hostname, - cves: row.cves_json ? JSON.parse(row.cves_json) : [], - }; - delete result.override_hostname; - res.status(201).json(result); - } - ); - } - ); + try { + const { rows } = await pool.query( + `INSERT INTO ivanti_todo_queue + (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type] + ); + + const result = { + ...rows[0], + cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], + }; + res.status(201).json(result); + } catch (err) { + console.error('Error adding to queue:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * PUT /api/ivanti/todo-queue/:id - * - * Update vendor, workflow_type, or status on a queue item — scoped to current user. - * - * @param {string} id - Queue item ID (URL parameter) - * @body {string} [vendor] - New vendor string (max 200 chars) - * @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE' - * @body {string} [status] - One of 'pending', 'complete' - * - * @returns {Object} 200 - Updated queue item with parsed cves array: - * id, user_id, finding_id, finding_title, cves_json, ip_address, - * vendor, workflow_type, status, created_at, updated_at, cves - * @returns {Object} 400 - { error: string } on validation failure or no fields to update - * @returns {Object} 404 - { error: string } if item not found for current user - * @returns {Object} 500 - { error: string } on database error - */ - router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // PUT /api/ivanti/todo-queue/:id + router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { vendor, workflow_type, status } = req.body; @@ -319,248 +190,160 @@ function createIvantiTodoQueueRouter(db, requireAuth) { return res.status(400).json({ error: 'status must be pending or complete.' }); } - db.get( - 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', - [id, req.user.id], - (err, existing) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } - if (!existing) { - return res.status(404).json({ error: 'Queue item not found.' }); - } - - const updates = []; - const params = []; - - if (vendor !== undefined) { - updates.push('vendor = ?'); - params.push(vendor.trim()); - } - if (workflow_type !== undefined) { - updates.push('workflow_type = ?'); - params.push(workflow_type); - } - if (status !== undefined) { - updates.push('status = ?'); - params.push(status); - } - - if (updates.length === 0) { - return res.status(400).json({ error: 'No fields to update.' }); - } - - updates.push('updated_at = CURRENT_TIMESTAMP'); - params.push(id, req.user.id); - - db.run( - `UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, - params, - function (err2) { - if (err2) { - console.error(err2); - return res.status(500).json({ error: 'Internal server error.' }); - } - db.get( - `SELECT q.*, o.value AS override_hostname - FROM ivanti_todo_queue q - LEFT JOIN ivanti_finding_overrides o - ON o.finding_id = q.finding_id AND o.field = 'hostName' - WHERE q.id = ?`, - [id], - (err3, row) => { - if (err3 || !row) { - return res.json({ message: 'Queue item updated.' }); - } - const result = { - ...row, - hostname: row.override_hostname || row.hostname, - cves: row.cves_json ? JSON.parse(row.cves_json) : [], - }; - delete result.override_hostname; - res.json(result); - } - ); - } - ); + try { + const { rows: existingRows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + if (!existingRows[0]) { + return res.status(404).json({ error: 'Queue item not found.' }); } - ); + + const updates = []; + const params = []; + let paramIndex = 1; + + if (vendor !== undefined) { + updates.push(`vendor = $${paramIndex++}`); + params.push(vendor.trim()); + } + if (workflow_type !== undefined) { + updates.push(`workflow_type = $${paramIndex++}`); + params.push(workflow_type); + } + if (status !== undefined) { + updates.push(`status = $${paramIndex++}`); + params.push(status); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update.' }); + } + + updates.push('updated_at = NOW()'); + params.push(id, req.user.id); + + await pool.query( + `UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex}`, + params + ); + + const { rows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1', [id] + ); + const result = { + ...rows[0], + cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], + }; + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * POST /api/ivanti/todo-queue/:id/redirect - * - * Redirect a completed queue item to a different workflow type. - * Creates a new pending item copying finding data from the original. - * - * @param {string} id - Original queue item ID (URL parameter) - * @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE' - * @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE - * - * @returns {Object} 201 - Newly created queue item with parsed cves array - * @returns {Object} 400 - { error: string } on validation failure or item not complete - * @returns {Object} 404 - { error: string } if item not found for current user - * @returns {Object} 500 - { error: string } on database error - */ - router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // POST /api/ivanti/todo-queue/:id/redirect + router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { workflow_type, vendor } = req.body; - // --- Validation --- if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } - if (!['CARD', 'GRANITE'].includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } } - if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); - // --- Fetch original item scoped to current user --- - db.get( - 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', - [id, req.user.id], - (err, original) => { - if (err) { - console.error('Error fetching queue item for redirect:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - if (!original) { - return res.status(404).json({ error: 'Queue item not found.' }); - } - if (original.status !== 'complete') { - return res.status(400).json({ error: 'Only completed queue items can be redirected.' }); - } - - // --- INSERT new row copying finding data from original --- - db.run( - `INSERT INTO ivanti_todo_queue - (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type], - function (insertErr) { - if (insertErr) { - console.error('Error inserting redirected queue item:', insertErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - const newId = this.lastID; - - // --- Fetch the inserted row --- - db.get( - `SELECT q.*, o.value AS override_hostname - FROM ivanti_todo_queue q - LEFT JOIN ivanti_finding_overrides o - ON o.finding_id = q.finding_id AND o.field = 'hostName' - WHERE q.id = ?`, - [newId], - (fetchErr, row) => { - if (fetchErr || !row) { - console.error('Error fetching redirected queue item:', fetchErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - // Audit log (fire-and-forget) - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'queue_item_redirected', - entityType: 'ivanti_todo_queue', - entityId: String(original.id), - details: { - original_workflow_type: original.workflow_type, - target_workflow_type: workflow_type, - new_item_id: newId, - vendor: vendorVal, - }, - ipAddress: req.ip, - }); - - const result = { - ...row, - hostname: row.override_hostname || row.hostname, - cves: row.cves_json ? JSON.parse(row.cves_json) : [], - }; - delete result.override_hostname; - return res.status(201).json(result); - } - ); - } - ); + try { + const { rows: origRows } = await pool.query( + 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + const original = origRows[0]; + if (!original) { + return res.status(404).json({ error: 'Queue item not found.' }); } - ); + if (original.status !== 'complete') { + return res.status(400).json({ error: 'Only completed queue items can be redirected.' }); + } + + const { rows } = await pool.query( + `INSERT INTO ivanti_todo_queue + (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type] + ); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'queue_item_redirected', + entityType: 'ivanti_todo_queue', + entityId: String(original.id), + details: { + original_workflow_type: original.workflow_type, + target_workflow_type: workflow_type, + new_item_id: rows[0].id, + vendor: vendorVal, + }, + ipAddress: req.ip, + }); + + const result = { + ...rows[0], + cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [], + }; + return res.status(201).json(result); + } catch (err) { + console.error('Error redirecting queue item:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * DELETE /api/ivanti/todo-queue/completed - * - * Bulk-delete all completed items for the current user. - * IMPORTANT: This route must be registered BEFORE DELETE /:id. - * - * @returns {Object} 200 - { message: string, deleted: number } - * @returns {Object} 500 - { error: string } on database error - */ - router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { - db.run( - "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", - [req.user.id], - function (err) { - if (err) { - console.error('Error clearing completed queue items:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } - res.json({ message: 'Completed items cleared.', deleted: this.changes }); - } - ); + // DELETE /api/ivanti/todo-queue/completed + router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + try { + const result = await pool.query( + "DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", + [req.user.id] + ); + res.json({ message: 'Completed items cleared.', deleted: result.rowCount }); + } catch (err) { + console.error('Error clearing completed queue items:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * DELETE /api/ivanti/todo-queue/:id - * - * Delete a single queue item — scoped to current user. - * - * @param {string} id - Queue item ID (URL parameter) - * - * @returns {Object} 200 - { message: string } - * @returns {Object} 404 - { error: string } if item not found for current user - * @returns {Object} 500 - { error: string } on database error - */ - router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // DELETE /api/ivanti/todo-queue/:id + router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - db.get( - 'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', - [id, req.user.id], - (err, row) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } - if (!row) { - return res.status(404).json({ error: 'Queue item not found.' }); - } - - db.run( - 'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', - [id, req.user.id], - function (err2) { - if (err2) { - console.error(err2); - return res.status(500).json({ error: 'Internal server error.' }); - } - res.json({ message: 'Queue item deleted.' }); - } - ); + try { + const { rows } = await pool.query( + 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + if (!rows[0]) { + return res.status(404).json({ error: 'Queue item not found.' }); } - ); + + await pool.query( + 'DELETE FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2', + [id, req.user.id] + ); + res.json({ message: 'Queue item deleted.' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); return router; diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js index 87ae896..c5a5d55 100644 --- a/backend/routes/ivantiWorkflows.js +++ b/backend/routes/ivantiWorkflows.js @@ -1,46 +1,17 @@ // Ivanti / RiskSense Workflow Routes -// Data is cached in SQLite and refreshed on a daily schedule or on-demand. -// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json) -// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited +// Data is cached in PostgreSQL and refreshed on a daily schedule or on-demand. const express = require('express'); -const { requireGroup } = require('../middleware/auth'); +const pool = require('../db'); +const { requireAuth, requireGroup } = require('../middleware/auth'); const { ivantiPost } = require('../helpers/ivantiApi'); const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours // --------------------------------------------------------------------------- -// Ensure the sync state table exists (idempotent — safe to call on every start) +// Core sync — calls Ivanti API, stores result in PostgreSQL // --------------------------------------------------------------------------- -function initTable(db) { - return new Promise((resolve, reject) => { - db.serialize(() => { - db.run(` - CREATE TABLE IF NOT EXISTS ivanti_sync_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - total INTEGER DEFAULT 0, - workflows_json TEXT DEFAULT '[]', - synced_at DATETIME, - sync_status TEXT DEFAULT 'never', - error_message TEXT - ) - `, (err) => { if (err) return reject(err); }); - - db.run(` - INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status) - VALUES (1, 0, '[]', 'never') - `, (err) => { - if (err) reject(err); - else resolve(); - }); - }); - }); -} - -// --------------------------------------------------------------------------- -// Core sync — calls Ivanti API, stores result in SQLite -// --------------------------------------------------------------------------- -async function syncWorkflows(db) { +async function syncWorkflows() { const apiKey = process.env.IVANTI_API_KEY; const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const firstName = process.env.IVANTI_FIRST_NAME || ''; @@ -50,12 +21,10 @@ async function syncWorkflows(db) { if (!apiKey) { const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync'; console.warn('[Ivanti]', errMsg); - await new Promise((resolve) => { - db.run( - `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, - [errMsg], resolve - ); - }); + await pool.query( + `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`, + [errMsg] + ); return; } @@ -107,7 +76,6 @@ async function syncWorkflows(db) { const data = JSON.parse(result.body); - // Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } } let total = 0; let workflows = []; @@ -127,95 +95,89 @@ async function syncWorkflows(db) { total = data.length; } - await new Promise((resolve, reject) => { - db.run( - `UPDATE ivanti_sync_state - SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL - WHERE id=1`, - [total, JSON.stringify(workflows)], - (err) => { if (err) reject(err); else resolve(); } - ); - }); + await pool.query( + `UPDATE ivanti_sync_state + SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL + WHERE id=1`, + [total, JSON.stringify(workflows)] + ); console.log(`[Ivanti] Sync complete — ${total} workflows`); } catch (err) { const msg = err.message || 'Unknown error'; console.error('[Ivanti] Sync failed:', msg); - await new Promise((resolve) => { - db.run( - `UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, - [msg], resolve - ); - }); + await pool.query( + `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`, + [msg] + ); } } // --------------------------------------------------------------------------- // Scheduler — runs sync immediately if >24h stale, then every 24h // --------------------------------------------------------------------------- -function scheduleSync(db) { - db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => { - if (err || !row || !row.synced_at) { - syncWorkflows(db); +async function scheduleSync() { + try { + const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1'); + const row = rows[0]; + if (!row || !row.synced_at) { + syncWorkflows(); } else { - const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z'); + const lastSync = new Date(row.synced_at); const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); if (hoursSince >= 24) { - syncWorkflows(db); + syncWorkflows(); } else { const hoursUntil = (24 - hoursSince).toFixed(1); console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`); } } - }); + } catch (err) { + console.error('[Ivanti] Schedule check failed:', err); + syncWorkflows(); + } - setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS); + setInterval(() => syncWorkflows(), SYNC_INTERVAL_MS); } // --------------------------------------------------------------------------- // Helper — read current state from DB and return as JSON-ready object // --------------------------------------------------------------------------- -function readState(db) { - return new Promise((resolve, reject) => { - db.get( - 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1', - (err, row) => { - if (err) return reject(err); - if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null }); +async function readState() { + const { rows } = await pool.query( + 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1' + ); + const row = rows[0]; + if (!row) return { total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null }; - let workflows = []; - try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } + let workflows = []; + try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } - resolve({ - total: row.total || 0, - workflows, - synced_at: row.synced_at, - sync_status: row.sync_status, - error_message: row.error_message - }); - } - ); - }); + return { + total: row.total || 0, + workflows, + synced_at: row.synced_at, + sync_status: row.sync_status, + error_message: row.error_message + }; } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- -function createIvantiWorkflowsRouter(db, requireAuth) { +function createIvantiWorkflowsRouter() { const router = express.Router(); - // Init table and kick off scheduler (fire-and-forget on startup) - initTable(db) - .then(() => scheduleSync(db)) - .catch((err) => console.error('[Ivanti] Init failed:', err)); + // Kick off scheduler (fire-and-forget on startup) + scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err)); // All routes require authentication - router.use(requireAuth(db)); + router.use(requireAuth()); // GET / — return cached data (fast, no external call) router.get('/', async (req, res) => { try { - res.json(await readState(db)); + res.json(await readState()); } catch { res.status(500).json({ error: 'Database error reading sync state' }); } @@ -223,9 +185,9 @@ function createIvantiWorkflowsRouter(db, requireAuth) { // POST /sync — trigger an immediate sync, await completion, return fresh state router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { - await syncWorkflows(db); + await syncWorkflows(); try { - res.json(await readState(db)); + res.json(await readState()); } catch { res.status(500).json({ error: 'Sync ran but could not read updated state' }); } diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index 996237b..9cd77ec 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -11,6 +11,7 @@ // - Rate limits enforced client-side (1440/day, 60/min burst) const express = require('express'); +const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); const jiraApi = require('../helpers/jiraApi'); @@ -27,24 +28,14 @@ function isValidVendor(vendor) { return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; } -function createJiraTicketsRouter(db) { +function createJiraTicketsRouter() { const router = express.Router(); // ----------------------------------------------------------------------- // Jira API integration endpoints // ----------------------------------------------------------------------- - /** - * GET /api/jira/connection-test - * - * Verify Jira credentials and connectivity by testing the configured - * Jira API connection. Admin only. - * - * @returns {object} 200 - { connected: true, user: { name, ... } } - * @returns {object} 502 - { connected: false, status, error } | { connected: false, error } - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.get('/connection-test', requireAuth(db), requireGroup('Admin'), async (req, res) => { + router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' }); } @@ -52,7 +43,7 @@ function createJiraTicketsRouter(db) { try { const result = await jiraApi.testConnection(); if (result.ok) { - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'jira_connection_test', @@ -69,32 +60,11 @@ function createJiraTicketsRouter(db) { } }); - /** - * GET /api/jira/rate-limit - * - * Return current Jira API rate limit usage. Admin only. - * - * @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } } - */ - router.get('/rate-limit', requireAuth(db), requireGroup('Admin'), (req, res) => { + router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => { res.json(jiraApi.getRateLimitStatus()); }); - /** - * GET /api/jira/lookup/:issueKey - * - * Fetch a single issue from Jira by its issue key (e.g., PROJECT-123). - * Uses explicit `?fields=` parameter per Charter Jira REST API requirement. - * - * @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123) - * @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self } - * @returns {object} 400 - { error } when issue key format is invalid - * @returns {object} 404 - { error } when issue not found in Jira - * @returns {object} 429 - { error } when Jira rate limit exceeded - * @returns {object} 502 - { error, details } on Jira API error - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.get('/lookup/:issueKey', requireAuth(db), async (req, res) => { + router.get('/lookup/:issueKey', requireAuth(), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); } @@ -132,27 +102,7 @@ function createJiraTicketsRouter(db) { } }); - /** - * POST /api/jira/create-in-jira - * - * Create a new issue in Jira via the REST API and insert a linked local - * record in the `jira_tickets` table. Requires Admin or Standard_User group. - * Subject to 2s write delay enforced by jiraApi. - * - * @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN) - * @body {string} vendor - Vendor name (required, max 200 chars) - * @body {string} summary - Issue summary (required, max 255 chars) - * @body {string} [description] - Issue description - * @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var) - * @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var) - * @returns {object} 201 - { id, ticket_key, jira_url, message } - * @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local save failed - * @returns {object} 400 - { error } on validation failure - * @returns {object} 429 - { error } when Jira rate limit exceeded - * @returns {object} 502 - { error, details } on Jira API failure - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.post('/create-in-jira', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); } @@ -201,191 +151,150 @@ function createJiraTicketsRouter(db) { ? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`) : null; - db.run( - `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`, - [cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id], - function(err) { - if (err) { - console.error('Error saving local Jira ticket record:', err); - return res.status(207).json({ - warning: 'Issue created in Jira but local record failed to save.', - jira_key: ticketKey, - jira_url: jiraUrl, - error: err.message - }); - } + try { + const { rows } = await pool.query( + `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) + RETURNING id`, + [cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id] + ); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_ticket_create_via_api', - entityType: 'jira_ticket', - entityId: this.lastID.toString(), - details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey }, - ipAddress: req.ip - }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_create_via_api', + entityType: 'jira_ticket', + entityId: rows[0].id.toString(), + details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey }, + ipAddress: req.ip + }); - res.status(201).json({ - id: this.lastID, - ticket_key: ticketKey, - jira_url: jiraUrl, - message: 'Jira issue created and linked successfully' - }); - } - ); + res.status(201).json({ + id: rows[0].id, + ticket_key: ticketKey, + jira_url: jiraUrl, + message: 'Jira issue created and linked successfully' + }); + } catch (dbErr) { + console.error('Error saving local Jira ticket record:', dbErr); + return res.status(207).json({ + warning: 'Issue created in Jira but local record failed to save.', + jira_key: ticketKey, + jira_url: jiraUrl, + error: dbErr.message + }); + } } catch (err) { return res.status(502).json({ error: err.message }); } }); - /** - * POST /api/jira/sync-all - * - * Bulk-sync all local tickets that have a Jira key by fetching their - * latest status from Jira. Uses a single JQL bulk search per batch - * instead of one GET per ticket (Charter-compliant). Stops early if - * the rate limit budget is running low. Admin only. - * - * @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] } - * @returns {object} 500 - { error } on database error - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.post('/sync-all', requireAuth(db), requireGroup('Admin'), async (req, res) => { + router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); } - db.all( - "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''", - [], - async (err, tickets) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); + try { + const { rows: tickets } = await pool.query( + "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''" + ); + + if (tickets.length === 0) { + return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); + } + + const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }; + + const BATCH_SIZE = 100; + const batches = []; + for (let i = 0; i < tickets.length; i += BATCH_SIZE) { + batches.push(tickets.slice(i, i + BATCH_SIZE)); + } + + for (const batch of batches) { + const rateStatus = jiraApi.getRateLimitStatus(); + if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) { + const remaining = tickets.length - results.synced - results.failed - results.unchanged; + results.skipped += remaining; + results.errors.push('Rate limit approaching — stopped sync early to preserve budget.'); + break; } - if (tickets.length === 0) { - return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); - } - - const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }; - - // Batch keys into groups of 100 for JQL (avoid overly long queries) - const BATCH_SIZE = 100; - const batches = []; - for (let i = 0; i < tickets.length; i += BATCH_SIZE) { - batches.push(tickets.slice(i, i + BATCH_SIZE)); - } - - for (const batch of batches) { - // Check rate limit before each batch - const rateStatus = jiraApi.getRateLimitStatus(); - if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) { - const remaining = tickets.length - results.synced - results.failed - results.unchanged; - results.skipped += remaining; - results.errors.push('Rate limit approaching — stopped sync early to preserve budget.'); - break; + const keys = batch.map(t => t.ticket_key); + try { + const result = await jiraApi.searchIssuesByKeys(keys); + if (!result.ok) { + if (result.rateLimited) { + results.skipped += batch.length; + results.errors.push('Jira rate limit hit during sync.'); + break; + } + results.failed += batch.length; + results.errors.push(`Batch search failed: HTTP ${result.status}`); + continue; } - const keys = batch.map(t => t.ticket_key); - try { - // Bulk JQL search — Charter-compliant, single request per batch - const result = await jiraApi.searchIssuesByKeys(keys); - if (!result.ok) { - if (result.rateLimited) { - results.skipped += batch.length; - results.errors.push('Jira rate limit hit during sync.'); - break; - } - results.failed += batch.length; - results.errors.push(`Batch search failed: HTTP ${result.status}`); + const issueMap = {}; + for (const issue of (result.data.issues || [])) { + issueMap[issue.key] = issue; + } + + for (const ticket of batch) { + const issue = issueMap[ticket.ticket_key]; + if (!issue) { + results.unchanged++; continue; } - // Build a map of key → Jira issue data - const issueMap = {}; - for (const issue of (result.data.issues || [])) { - issueMap[issue.key] = issue; + const jiraStatus = issue.fields.status ? issue.fields.status.name : null; + const jiraSummary = issue.fields.summary || ticket.summary; + const localStatus = mapJiraStatusToLocal(jiraStatus); + + try { + await pool.query( + `UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`, + [jiraSummary, localStatus, jiraStatus, ticket.id] + ); + results.synced++; + } catch (dbErr) { + results.failed++; + results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`); } - - // Update each local ticket from the search results - for (const ticket of batch) { - const issue = issueMap[ticket.ticket_key]; - if (!issue) { - // Issue not returned — either not updated in last 24h or not found - results.unchanged++; - continue; - } - - const jiraStatus = issue.fields.status ? issue.fields.status.name : null; - const jiraSummary = issue.fields.summary || ticket.summary; - const localStatus = mapJiraStatusToLocal(jiraStatus); - - try { - await new Promise((resolve, reject) => { - db.run( - `UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - [jiraSummary, localStatus, jiraStatus, ticket.id], - (updateErr) => updateErr ? reject(updateErr) : resolve() - ); - }); - results.synced++; - } catch (dbErr) { - results.failed++; - results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`); - } - } - } catch (searchErr) { - results.failed += batch.length; - results.errors.push(`Batch search error: ${searchErr.message}`); } + } catch (searchErr) { + results.failed += batch.length; + results.errors.push(`Batch search error: ${searchErr.message}`); } - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_sync_all', - entityType: 'jira_integration', - entityId: null, - details: results, - ipAddress: req.ip - }); - - res.json(results); } - ); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_sync_all', + entityType: 'jira_integration', + entityId: null, + details: results, + ipAddress: req.ip + }); + + res.json(results); + } catch (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * POST /api/jira/:id/sync - * - * Sync a single local ticket with Jira by fetching the latest status, - * summary, and mapping the Jira status to the local three-state model. - * Uses getIssue with explicit fields (Charter-compliant GET). - * Requires Admin or Standard_User group. - * - * @param {number} id - Local jira_tickets row ID (path parameter) - * @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary } - * @returns {object} 400 - { error } when ticket has no Jira key - * @returns {object} 404 - { error } when local ticket not found - * @returns {object} 429 - { error } when Jira rate limit exceeded - * @returns {object} 500 - { error } on database error - * @returns {object} 502 - { error, details } on Jira API failure - * @returns {object} 503 - { error } when Jira API is not configured - */ - router.post('/:id/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => { + router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); } const { id } = req.params; - db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]); + const ticket = rows[0]; + if (!ticket) { return res.status(404).json({ error: 'JIRA ticket not found.' }); } @@ -393,117 +302,83 @@ function createJiraTicketsRouter(db) { return res.status(400).json({ error: 'Ticket has no Jira key to sync.' }); } - try { - const result = await jiraApi.getIssue(ticket.ticket_key); - if (!result.ok) { - if (result.rateLimited) { - return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' }); - } - return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body }); + const result = await jiraApi.getIssue(ticket.ticket_key); + if (!result.ok) { + if (result.rateLimited) { + return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' }); } - - const issue = result.data; - const jiraStatus = issue.fields.status ? issue.fields.status.name : null; - const jiraSummary = issue.fields.summary || ticket.summary; - const localStatus = mapJiraStatusToLocal(jiraStatus); - - db.run( - `UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - [jiraSummary, localStatus, jiraStatus, id], - function(updateErr) { - if (updateErr) { - console.error('Error updating synced ticket:', updateErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_ticket_sync', - entityType: 'jira_ticket', - entityId: id, - details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus }, - ipAddress: req.ip - }); - - res.json({ - message: 'Ticket synced with Jira', - ticket_key: ticket.ticket_key, - jira_status: jiraStatus, - local_status: localStatus, - summary: jiraSummary - }); - } - ); - } catch (err) { - return res.status(502).json({ error: err.message }); + return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body }); } - }); + + const issue = result.data; + const jiraStatus = issue.fields.status ? issue.fields.status.name : null; + const jiraSummary = issue.fields.summary || ticket.summary; + const localStatus = mapJiraStatusToLocal(jiraStatus); + + await pool.query( + `UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`, + [jiraSummary, localStatus, jiraStatus, id] + ); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_sync', + entityType: 'jira_ticket', + entityId: id, + details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus }, + ipAddress: req.ip + }); + + res.json({ + message: 'Ticket synced with Jira', + ticket_key: ticket.ticket_key, + jira_status: jiraStatus, + local_status: localStatus, + summary: jiraSummary + }); + } catch (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } }); // ----------------------------------------------------------------------- - // Local CRUD endpoints (migrated from server.js) + // Local CRUD endpoints // ----------------------------------------------------------------------- - /** - * GET /api/jira - * - * List all local JIRA ticket records with optional filters. - * Results are ordered by `created_at` descending. - * - * @query {string} [cve_id] - Filter by CVE ID - * @query {string} [vendor] - Filter by vendor name - * @query {string} [status] - Filter by ticket status (Open, In Progress, Closed) - * @returns {object[]} 200 - Array of jira_tickets rows - * @returns {object} 500 - { error } on database error - */ - router.get('/', requireAuth(db), (req, res) => { + router.get('/', requireAuth(), async (req, res) => { const { cve_id, vendor, status } = req.query; let query = 'SELECT * FROM jira_tickets WHERE 1=1'; const params = []; + let paramIndex = 1; if (cve_id) { - query += ' AND cve_id = ?'; + query += ` AND cve_id = $${paramIndex++}`; params.push(cve_id); } if (vendor) { - query += ' AND vendor = ?'; + query += ` AND vendor = $${paramIndex++}`; params.push(vendor); } if (status) { - query += ' AND status = ?'; + query += ` AND status = $${paramIndex++}`; params.push(status); } query += ' ORDER BY created_at DESC'; - db.all(query, params, (err, rows) => { - if (err) { - console.error('Error fetching JIRA tickets:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query(query, params); res.json(rows); - }); + } catch (err) { + console.error('Error fetching JIRA tickets:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * POST /api/jira - * - * Create a local JIRA ticket record (manual entry, no Jira API call). - * Requires Admin or Standard_User group. - * - * @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN) - * @body {string} vendor - Vendor name (required, max 200 chars) - * @body {string} ticket_key - Jira issue key (required, max 50 chars) - * @body {string} [url] - URL to the Jira issue (max 500 chars) - * @body {string} [summary] - Ticket summary (max 500 chars) - * @body {string} [status] - Ticket status: Open, In Progress, or Closed (defaults to Open) - * @returns {object} 201 - { id, message } - * @returns {object} 400 - { error } on validation failure - * @returns {object} 500 - { error } on database error - */ - router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cve_id, vendor, ticket_key, url, summary, status } = req.body; if (!cve_id || !isValidCveId(cve_id)) { @@ -527,51 +402,35 @@ function createJiraTicketsRouter(db) { const ticketStatus = status || 'Open'; - db.run( - `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], - function(err) { - if (err) { - console.error('Error creating JIRA ticket:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query( + `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id`, + [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id] + ); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_ticket_create', - entityType: 'jira_ticket', - entityId: this.lastID.toString(), - details: { cve_id, vendor, ticket_key, status: ticketStatus }, - ipAddress: req.ip - }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_create', + entityType: 'jira_ticket', + entityId: rows[0].id.toString(), + details: { cve_id, vendor, ticket_key, status: ticketStatus }, + ipAddress: req.ip + }); - res.status(201).json({ - id: this.lastID, - message: 'JIRA ticket created successfully' - }); - } - ); + res.status(201).json({ + id: rows[0].id, + message: 'JIRA ticket created successfully' + }); + } catch (err) { + console.error('Error creating JIRA ticket:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * PUT /api/jira/:id - * - * Update a local JIRA ticket record. Only provided fields are updated. - * Requires Admin or Standard_User group. - * - * @param {number} id - Local jira_tickets row ID (path parameter) - * @body {string} [ticket_key] - Jira issue key (max 50 chars) - * @body {string} [url] - URL to the Jira issue (max 500 chars, or null) - * @body {string} [summary] - Ticket summary (max 500 chars, or null) - * @body {string} [status] - Ticket status: Open, In Progress, or Closed - * @returns {object} 200 - { message, changes } - * @returns {object} 400 - { error } on validation failure or no fields provided - * @returns {object} 404 - { error } when ticket not found - * @returns {object} 500 - { error } on database error - */ - router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { ticket_key, url, summary, status } = req.body; @@ -590,70 +449,56 @@ function createJiraTicketsRouter(db) { const fields = []; const values = []; + let paramIndex = 1; - if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); } - if (url !== undefined) { fields.push('url = ?'); values.push(url); } - if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); } - if (status !== undefined) { fields.push('status = ?'); values.push(status); } + if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); } + if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); } + if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); } + if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); } if (fields.length === 0) { return res.status(400).json({ error: 'No fields to update.' }); } - fields.push('updated_at = CURRENT_TIMESTAMP'); + fields.push('updated_at = NOW()'); values.push(id); - db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]); + const existing = rows[0]; if (!existing) { return res.status(404).json({ error: 'JIRA ticket not found.' }); } - db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) { - if (updateErr) { - console.error('Error updating JIRA ticket:', updateErr); - return res.status(500).json({ error: 'Internal server error.' }); - } + const result = await pool.query( + `UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`, + values + ); - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_ticket_update', - entityType: 'jira_ticket', - entityId: id, - details: { before: existing, changes: req.body }, - ipAddress: req.ip - }); - - res.json({ message: 'JIRA ticket updated successfully', changes: this.changes }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_update', + entityType: 'jira_ticket', + entityId: id, + details: { before: existing, changes: req.body }, + ipAddress: req.ip }); - }); + + res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount }); + } catch (err) { + console.error('Error updating JIRA ticket:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); - /** - * DELETE /api/jira/:id - * - * Delete a local JIRA ticket record. Admins bypass all restrictions. - * Standard_User can only delete tickets they created, and cannot delete - * tickets linked to active compliance items. - * - * @param {number} id - Local jira_tickets row ID (path parameter) - * @returns {object} 200 - { message } - * @returns {object} 403 - { error } when ownership check fails or ticket is linked to compliance - * @returns {object} 404 - { error } when ticket not found - * @returns {object} 500 - { error } on database error - */ - router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]); + const ticket = rows[0]; + if (!ticket) { return res.status(404).json({ error: 'JIRA ticket not found.' }); } @@ -670,54 +515,48 @@ function createJiraTicketsRouter(db) { // Standard_User: compliance linkage check const ticketKey = ticket.ticket_key; - db.all( - `SELECT ci.id, ci.extra_json - FROM compliance_items ci - JOIN compliance_uploads cu ON ci.upload_id = cu.id - WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, - [`%${ticketKey}%`], - (compErr, compLinks) => { - if (compErr && compErr.message && compErr.message.includes('no such table')) { - compLinks = []; - } else if (compErr) { - console.error(compErr); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows: compLinks } = await pool.query( + `SELECT ci.id, ci.extra_json + FROM compliance_items ci + JOIN compliance_uploads cu ON ci.upload_id = cu.id + WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`, + [`%${ticketKey}%`] + ); - const isLinked = (compLinks || []).some(cl => { - const json = cl.extra_json || ''; - return json.includes(ticketKey); - }); - - if (isLinked) { - return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); - } - - return performJiraDelete(); - } - ); - - function performJiraDelete() { - db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) { - if (deleteErr) { - console.error('Error deleting JIRA ticket:', deleteErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'jira_ticket_delete', - entityType: 'jira_ticket', - entityId: id, - details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor }, - ipAddress: req.ip - }); - - res.json({ message: 'JIRA ticket deleted successfully' }); + const isLinked = (compLinks || []).some(cl => { + const json = cl.extra_json || ''; + return json.includes(ticketKey); }); + + if (isLinked) { + return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); + } + } catch (compErr) { + if (!compErr.message.includes('does not exist')) throw compErr; } - }); + + return performJiraDelete(); + + async function performJiraDelete() { + await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'jira_ticket_delete', + entityType: 'jira_ticket', + entityId: id, + details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor }, + ipAddress: req.ip + }); + + res.json({ message: 'JIRA ticket deleted successfully' }); + } + } catch (err) { + console.error('Error deleting JIRA ticket:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); return router; @@ -727,10 +566,6 @@ function createJiraTicketsRouter(db) { // Helpers // --------------------------------------------------------------------------- -/** - * Map a Jira workflow status name to the local three-state model. - * Jira statuses vary by project workflow, so this uses broad categories. - */ function mapJiraStatusToLocal(jiraStatus) { if (!jiraStatus) return 'Open'; const lower = jiraStatus.toLowerCase(); diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js index 90ba73d..87a5553 100644 --- a/backend/routes/knowledgeBase.js +++ b/backend/routes/knowledgeBase.js @@ -1,10 +1,11 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); +const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); -function createKnowledgeBaseRouter(db, upload) { +function createKnowledgeBaseRouter(upload) { const router = express.Router(); // Helper to sanitize filename @@ -39,20 +40,8 @@ function createKnowledgeBaseRouter(db, upload) { return ALLOWED_EXTENSIONS.has(ext); } - /** - * POST /api/knowledge-base/upload - * Upload a new knowledge base document. - * - * @body {string} title - Article title (required) - * @body {string} [description] - Article description - * @body {string} [category] - Article category (defaults to 'General') - * @body {File} file - The document file to upload (multipart/form-data) - * - * @response 200 - { success: true, id: number, title: string, slug: string, category: string } - * @response 400 - { error: string } - Missing title, no file, or invalid file type - * @response 500 - { error: string } - Database or filesystem error - */ - router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => { + // POST /api/knowledge-base/upload + router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => { upload.single('file')(req, res, (err) => { if (err) { console.error('[KB Upload] Multer error:', err); @@ -70,7 +59,6 @@ function createKnowledgeBaseRouter(db, upload) { const uploadedFile = req.file; const { title, description, category } = req.body; - // Validate required fields if (!title || !title.trim()) { console.error('[KB Upload] Error: Title is missing'); if (uploadedFile) fs.unlinkSync(uploadedFile.path); @@ -81,7 +69,6 @@ function createKnowledgeBaseRouter(db, upload) { return res.status(400).json({ error: 'No file uploaded' }); } - // Validate file type if (!isValidFileType(uploadedFile.originalname)) { fs.unlinkSync(uploadedFile.path); return res.status(400).json({ error: 'File type not allowed' }); @@ -96,172 +83,121 @@ function createKnowledgeBaseRouter(db, upload) { const filePath = path.join(kbDir, filename); try { - // Keep file in temp location until DB insert succeeds // Check if slug already exists - db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => { - if (err) { - fs.unlinkSync(uploadedFile.path); - console.error('Error checking slug:', err); - return res.status(500).json({ error: 'Database error' }); + const { rows: existingRows } = await pool.query( + 'SELECT id FROM knowledge_base WHERE slug = $1', [slug] + ); + + const finalSlug = existingRows.length > 0 ? `${slug}-${timestamp}` : slug; + + // Insert new knowledge base entry + const { rows } = await pool.query( + `INSERT INTO knowledge_base ( + title, slug, description, category, file_path, file_name, + file_type, file_size, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id`, + [ + title.trim(), + finalSlug, + description || null, + category || 'General', + filePath, + sanitizedName, + uploadedFile.mimetype, + uploadedFile.size, + req.user.id + ] + ); + + // DB insert succeeded — now move file to permanent location + try { + if (!fs.existsSync(kbDir)) { + fs.mkdirSync(kbDir, { recursive: true }); } + fs.renameSync(uploadedFile.path, filePath); + } catch (moveErr) { + console.error('Error moving file to permanent location:', moveErr); + } - // If slug exists, append timestamp to make it unique - const finalSlug = row ? `${slug}-${timestamp}` : slug; + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'CREATE_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(rows[0].id), + details: { title: title.trim(), filename: sanitizedName }, + ipAddress: req.ip + }); - // Insert new knowledge base entry - const insertSql = ` - INSERT INTO knowledge_base ( - title, slug, description, category, file_path, file_name, - file_type, file_size, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `; - - db.run( - insertSql, - [ - title.trim(), - finalSlug, - description || null, - category || 'General', - filePath, - sanitizedName, - uploadedFile.mimetype, - uploadedFile.size, - req.user.id - ], - function (err) { - if (err) { - fs.unlinkSync(uploadedFile.path); - console.error('Error inserting knowledge base entry:', err); - return res.status(500).json({ error: 'Failed to save document metadata' }); - } - - // DB insert succeeded — now move file to permanent location - try { - if (!fs.existsSync(kbDir)) { - fs.mkdirSync(kbDir, { recursive: true }); - } - fs.renameSync(uploadedFile.path, filePath); - } catch (moveErr) { - console.error('Error moving file to permanent location:', moveErr); - // File is orphaned in temp but DB record exists — log and continue - } - - // Log audit entry - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'CREATE_KB_ARTICLE', - entityType: 'knowledge_base', - entityId: String(this.lastID), - details: { title: title.trim(), filename: sanitizedName }, - ipAddress: req.ip - }); - - res.json({ - success: true, - id: this.lastID, - title: title.trim(), - slug: finalSlug, - category: category || 'General' - }); - } - ); + res.json({ + success: true, + id: rows[0].id, + title: title.trim(), + slug: finalSlug, + category: category || 'General' }); } catch (error) { - // Clean up temp file on error if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path); console.error('Error uploading knowledge base document:', error); res.status(500).json({ error: error.message || 'Failed to upload document' }); } }); - /** - * GET /api/knowledge-base - * List all knowledge base articles. - * - * @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }] - * @response 500 - { error: string } - */ - router.get('/', requireAuth(db), (req, res) => { - const sql = ` - SELECT - kb.id, kb.title, kb.slug, kb.description, kb.category, - kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, - u.username as created_by_username - FROM knowledge_base kb - LEFT JOIN users u ON kb.created_by = u.id - ORDER BY kb.created_at DESC - `; - - db.all(sql, [], (err, rows) => { - if (err) { - console.error('Error fetching knowledge base articles:', err); - return res.status(500).json({ error: 'Failed to fetch articles' }); - } - + // GET /api/knowledge-base + router.get('/', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query(` + SELECT + kb.id, kb.title, kb.slug, kb.description, kb.category, + kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, + u.username as created_by_username + FROM knowledge_base kb + LEFT JOIN users u ON kb.created_by = u.id + ORDER BY kb.created_at DESC + `); res.json(rows); - }); + } catch (err) { + console.error('Error fetching knowledge base articles:', err); + res.status(500).json({ error: 'Failed to fetch articles' }); + } }); - /** - * GET /api/knowledge-base/:id - * Get a single article's details by ID. - * - * @param {string} id - Article ID (route parameter) - * - * @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username } - * @response 404 - { error: 'Article not found' } - * @response 500 - { error: string } - */ - router.get('/:id', requireAuth(db), (req, res) => { + // GET /api/knowledge-base/:id + router.get('/:id', requireAuth(), async (req, res) => { const { id } = req.params; - const sql = ` - SELECT - kb.id, kb.title, kb.slug, kb.description, kb.category, - kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, - u.username as created_by_username - FROM knowledge_base kb - LEFT JOIN users u ON kb.created_by = u.id - WHERE kb.id = ? - `; + try { + const { rows } = await pool.query(` + SELECT + kb.id, kb.title, kb.slug, kb.description, kb.category, + kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, + u.username as created_by_username + FROM knowledge_base kb + LEFT JOIN users u ON kb.created_by = u.id + WHERE kb.id = $1 + `, [id]); - db.get(sql, [id], (err, row) => { - if (err) { - console.error('Error fetching article:', err); - return res.status(500).json({ error: 'Failed to fetch article' }); - } - - if (!row) { + if (!rows[0]) { return res.status(404).json({ error: 'Article not found' }); } - res.json(row); - }); + res.json(rows[0]); + } catch (err) { + console.error('Error fetching article:', err); + res.status(500).json({ error: 'Failed to fetch article' }); + } }); - /** - * GET /api/knowledge-base/:id/content - * Get document content for inline display. Returns the raw file with appropriate - * Content-Type headers. Markdown and text files are served as text/plain. - * - * @param {string} id - Article ID (route parameter) - * - * @response 200 - Raw file content with Content-Type and Content-Disposition headers - * @response 404 - { error: string } - Article or file not found - * @response 500 - { error: string } - */ - router.get('/:id/content', requireAuth(db), (req, res) => { + // GET /api/knowledge-base/:id/content + router.get('/:id/content', requireAuth(), async (req, res) => { const { id } = req.params; - const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; - - db.get(sql, [id], (err, row) => { - if (err) { - console.error('Error fetching document:', err); - return res.status(500).json({ error: 'Failed to fetch document' }); - } + try { + const { rows } = await pool.query( + 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id] + ); + const row = rows[0]; if (!row) { return res.status(404).json({ error: 'Document not found' }); @@ -271,8 +207,7 @@ function createKnowledgeBaseRouter(db, upload) { return res.status(404).json({ error: 'File not found on disk' }); } - // Log audit entry - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'VIEW_KB_ARTICLE', @@ -282,10 +217,7 @@ function createKnowledgeBaseRouter(db, upload) { ipAddress: req.ip }); - // Determine content type for inline display let contentType = row.file_type || 'application/octet-stream'; - - // For markdown files, send as plain text so frontend can parse it if (row.file_name.endsWith('.md')) { contentType = 'text/plain; charset=utf-8'; } else if (row.file_name.endsWith('.txt')) { @@ -294,36 +226,26 @@ function createKnowledgeBaseRouter(db, upload) { const safeFileName = row.file_name.replace(/["\r\n\\]/g, ''); res.setHeader('Content-Type', contentType); - // Use inline instead of attachment to allow browser to display res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`); - // Allow iframe embedding from frontend origin res.removeHeader('X-Frame-Options'); const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000'; res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`); res.sendFile(row.file_path); - }); + } catch (err) { + console.error('Error fetching document:', err); + res.status(500).json({ error: 'Failed to fetch document' }); + } }); - /** - * GET /api/knowledge-base/:id/download - * Download a knowledge base document as an attachment. - * - * @param {string} id - Article ID (route parameter) - * - * @response 200 - File download with Content-Disposition: attachment header - * @response 404 - { error: string } - Article or file not found - * @response 500 - { error: string } - */ - router.get('/:id/download', requireAuth(db), (req, res) => { + // GET /api/knowledge-base/:id/download + router.get('/:id/download', requireAuth(), async (req, res) => { const { id } = req.params; - const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; - - db.get(sql, [id], (err, row) => { - if (err) { - console.error('Error fetching document:', err); - return res.status(500).json({ error: 'Failed to fetch document' }); - } + try { + const { rows } = await pool.query( + 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id] + ); + const row = rows[0]; if (!row) { return res.status(404).json({ error: 'Document not found' }); @@ -333,8 +255,7 @@ function createKnowledgeBaseRouter(db, upload) { return res.status(404).json({ error: 'File not found on disk' }); } - // Log audit entry - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'DOWNLOAD_KB_ARTICLE', @@ -348,31 +269,21 @@ function createKnowledgeBaseRouter(db, upload) { res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`); res.sendFile(row.file_path); - }); + } catch (err) { + console.error('Error fetching document:', err); + res.status(500).json({ error: 'Failed to fetch document' }); + } }); - /** - * DELETE /api/knowledge-base/:id - * Delete a knowledge base article and its associated file. - * Standard_User can only delete articles they created. Admin can delete any article. - * - * @param {string} id - Article ID (route parameter) - * - * @response 200 - { success: true } - * @response 403 - { error: string } - Ownership check failed for Standard_User - * @response 404 - { error: 'Article not found' } - * @response 500 - { error: string } - */ - router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + // DELETE /api/knowledge-base/:id + router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?'; - - db.get(sql, [id], (err, row) => { - if (err) { - console.error('Error fetching article for deletion:', err); - return res.status(500).json({ error: 'Failed to fetch article' }); - } + try { + const { rows } = await pool.query( + 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = $1', [id] + ); + const row = rows[0]; if (!row) { return res.status(404).json({ error: 'Article not found' }); @@ -383,32 +294,28 @@ function createKnowledgeBaseRouter(db, upload) { return res.status(403).json({ error: 'You can only delete resources you created' }); } - // Delete database record - db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { - if (err) { - console.error('Error deleting article:', err); - return res.status(500).json({ error: 'Failed to delete article' }); - } + await pool.query('DELETE FROM knowledge_base WHERE id = $1', [id]); - // Delete file - if (fs.existsSync(row.file_path)) { - fs.unlinkSync(row.file_path); - } + // Delete file + if (fs.existsSync(row.file_path)) { + fs.unlinkSync(row.file_path); + } - // Log audit entry - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'DELETE_KB_ARTICLE', - entityType: 'knowledge_base', - entityId: String(id), - details: { title: row.title }, - ipAddress: req.ip - }); - - res.json({ success: true }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'DELETE_KB_ARTICLE', + entityType: 'knowledge_base', + entityId: String(id), + details: { title: row.title }, + ipAddress: req.ip }); - }); + + res.json({ success: true }); + } catch (err) { + console.error('Error deleting article:', err); + res.status(500).json({ error: 'Failed to delete article' }); + } }); return router; diff --git a/backend/routes/nvdLookup.js b/backend/routes/nvdLookup.js index c82413f..fa7796d 100644 --- a/backend/routes/nvdLookup.js +++ b/backend/routes/nvdLookup.js @@ -1,13 +1,14 @@ // NVD CVE Lookup Routes const express = require('express'); +const { requireAuth } = require('../middleware/auth'); const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/; -function createNvdLookupRouter(db, requireAuth) { +function createNvdLookupRouter() { const router = express.Router(); // All routes require authentication - router.use(requireAuth(db)); + router.use(requireAuth()); // Lookup CVE details from NVD API 2.0 router.get('/lookup/:cveId', async (req, res) => { diff --git a/backend/routes/users.js b/backend/routes/users.js index e7cb9f4..64ea2fa 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,27 +1,22 @@ // User Management Routes (Admin only) const express = require('express'); const bcrypt = require('bcryptjs'); +const pool = require('../db'); const { validateTeams } = require('../helpers/teams'); -function createUsersRouter(db, requireAuth, requireGroup, logAudit) { +function createUsersRouter(requireAuth, requireGroup, logAudit) { const router = express.Router(); // All routes require Admin group - router.use(requireAuth(db), requireGroup('Admin')); + router.use(requireAuth(), requireGroup('Admin')); // Get all users router.get('/', async (req, res) => { try { - const users = await new Promise((resolve, reject) => { - db.all( - `SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login - FROM users ORDER BY created_at DESC`, - (err, rows) => { - if (err) reject(err); - else resolve(rows); - } - ); - }); + const { rows: users } = await pool.query( + `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login + FROM users ORDER BY created_at DESC` + ); // Parse bu_teams into teams array for each user const usersWithTeams = users.map(u => ({ ...u, @@ -37,17 +32,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // Get single user router.get('/:id', async (req, res) => { try { - const user = await new Promise((resolve, reject) => { - db.get( - `SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login - FROM users WHERE id = ?`, - [req.params.id], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows } = await pool.query( + `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login + FROM users WHERE id = $1`, + [req.params.id] + ); + + const user = rows[0]; if (!user) { return res.status(404).json({ error: 'User not found' }); @@ -90,19 +81,16 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { try { const passwordHash = await bcrypt.hash(password, 10); - const result = await new Promise((resolve, reject) => { - db.run( - `INSERT INTO users (username, email, password_hash, user_group, bu_teams) - VALUES (?, ?, ?, ?, ?)`, - [username, email, passwordHash, userGroup, teamsStr], - function(err) { - if (err) reject(err); - else resolve({ id: this.lastID }); - } - ); - }); + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, user_group, bu_teams) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [username, email, passwordHash, userGroup, teamsStr] + ); - logAudit(db, { + const result = rows[0]; + + logAudit({ userId: req.user.id, username: req.user.username, action: 'user_create', @@ -125,7 +113,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { }); } catch (err) { console.error('Create user error:', err); - if (err.message.includes('UNIQUE constraint failed')) { + if (err.code === '23505') { // Postgres unique violation return res.status(409).json({ error: 'Username or email already exists' }); } res.status(500).json({ error: 'Failed to create user' }); @@ -165,16 +153,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { try { // Fetch current user record before update (needed for group change audit) - const currentUser = await new Promise((resolve, reject) => { - db.get( - 'SELECT user_group, bu_teams FROM users WHERE id = ?', - [userId], - (err, row) => { - if (err) reject(err); - else resolve(row); - } - ); - }); + const { rows: currentRows } = await pool.query( + 'SELECT user_group, bu_teams FROM users WHERE id = $1', + [userId] + ); + + const currentUser = currentRows[0]; if (!currentUser) { return res.status(404).json({ error: 'User not found' }); @@ -182,30 +166,31 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { const updates = []; const values = []; + let paramIndex = 1; if (username) { - updates.push('username = ?'); + updates.push(`username = $${paramIndex++}`); values.push(username); } if (email) { - updates.push('email = ?'); + updates.push(`email = $${paramIndex++}`); values.push(email); } if (password) { const passwordHash = await bcrypt.hash(password, 10); - updates.push('password_hash = ?'); + updates.push(`password_hash = $${paramIndex++}`); values.push(passwordHash); } if (group) { - updates.push('user_group = ?'); + updates.push(`user_group = $${paramIndex++}`); values.push(group); } if (typeof is_active === 'boolean') { - updates.push('is_active = ?'); - values.push(is_active ? 1 : 0); + updates.push(`is_active = $${paramIndex++}`); + values.push(is_active); } if (typeof bu_teams === 'string') { - updates.push('bu_teams = ?'); + updates.push(`bu_teams = $${paramIndex++}`); values.push(bu_teams); } @@ -215,16 +200,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { values.push(userId); - await new Promise((resolve, reject) => { - db.run( - `UPDATE users SET ${updates.join(', ')} WHERE id = ?`, - values, - function(err) { - if (err) reject(err); - else resolve({ changes: this.changes }); - } - ); - }); + await pool.query( + `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`, + values + ); const updatedFields = {}; if (username) updatedFields.username = username; @@ -234,7 +213,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { if (password) updatedFields.password_changed = true; if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams; - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'user_update', @@ -246,7 +225,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // Log specific audit entry for group changes if (group && group !== currentUser.user_group) { - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'user_group_change', @@ -262,7 +241,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // Log specific audit entry for bu_teams changes if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) { - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'user_teams_change', @@ -278,15 +257,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { // If user was deactivated, delete their sessions if (is_active === false) { - await new Promise((resolve) => { - db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve()); - }); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]); } res.json({ message: 'User updated successfully' }); } catch (err) { console.error('Update user error:', err); - if (err.message.includes('UNIQUE constraint failed')) { + if (err.code === '23505') { // Postgres unique violation return res.status(409).json({ error: 'Username or email already exists' }); } res.status(500).json({ error: 'Failed to update user' }); @@ -304,31 +281,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) { try { // Look up the user before deleting - const targetUser = await new Promise((resolve, reject) => { - db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => { - if (err) reject(err); - else resolve(row); - }); - }); + const { rows: userRows } = await pool.query( + 'SELECT username FROM users WHERE id = $1', + [userId] + ); + const targetUser = userRows[0]; // Delete sessions first (foreign key) - await new Promise((resolve) => { - db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve()); - }); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]); // Delete user - const result = await new Promise((resolve, reject) => { - db.run('DELETE FROM users WHERE id = ?', [userId], function(err) { - if (err) reject(err); - else resolve({ changes: this.changes }); - }); - }); + const result = await pool.query('DELETE FROM users WHERE id = $1', [userId]); - if (result.changes === 0) { + if (result.rowCount === 0) { return res.status(404).json({ error: 'User not found' }); } - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'user_delete', diff --git a/backend/server.js b/backend/server.js index 1362420..fc41c5b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,16 +1,18 @@ // CVE Management Backend API -// Install: npm install express sqlite3 multer cors dotenv bcryptjs cookie-parser +// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser require('dotenv').config(); const express = require('express'); -const sqlite3 = require('sqlite3').verbose(); const multer = require('multer'); const cors = require('cors'); const cookieParser = require('cookie-parser'); const path = require('path'); const fs = require('fs'); +// PostgreSQL connection pool +const pool = require('./db'); + // Auth imports const { requireAuth, requireGroup } = require('./middleware/auth'); const createAuthRouter = require('./routes/auth'); @@ -133,50 +135,17 @@ app.use('/uploads', express.static('uploads', { index: false })); -// Database connection -const db = new sqlite3.Database('./cve_database.db', (err) => { - if (err) { - console.error('Database connection error:', err); - return; - } - console.log('Connected to CVE database'); - - // Ensure ivanti_todo_queue table exists (idempotent migration) - db.run(` - CREATE TABLE IF NOT EXISTS ivanti_todo_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - finding_id TEXT NOT NULL, - finding_title TEXT, - cves_json TEXT, - ip_address TEXT, - vendor TEXT NOT NULL, - workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')), - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `, (err2) => { - if (err2) console.error('Failed to create ivanti_todo_queue table:', err2); - else db.run( - 'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)', - (err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); } - ); - }); -}); - // Auth routes (public) -app.use('/api/auth', createAuthRouter(db, logAudit)); +app.use('/api/auth', createAuthRouter(logAudit)); // User management routes (admin only) -app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit)); +app.use('/api/users', createUsersRouter(requireAuth, requireGroup, logAudit)); // Audit log routes (admin only) -app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup)); +app.use('/api/audit-logs', createAuditLogRouter()); // NVD lookup routes (authenticated users) -app.use('/api/nvd', createNvdLookupRouter(db, requireAuth)); +app.use('/api/nvd', createNvdLookupRouter()); // Simple storage - upload to temp directory first const storage = multer.diskStorage({ @@ -215,96 +184,102 @@ const upload = multer({ }); // Knowledge base routes (editor/admin for upload/delete, all authenticated for view) -app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload)); +app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload)); // Archer tickets routes (editor/admin for create/update/delete, all authenticated for view) -app.use('/api/archer-tickets', createArcherTicketsRouter(db)); +app.use('/api/archer-tickets', createArcherTicketsRouter()); // Ivanti / RiskSense workflow routes (all authenticated users) -app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth)); +app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter()); // Ivanti / RiskSense host findings routes (all authenticated users) -app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth)); +// NOTE: Still passes pool as db until task 8 migrates this route +app.use('/api/ivanti/findings', createIvantiFindingsRouter(pool, requireAuth)); // Ivanti queue routes — per-user staging queue for FP / Archer workflows -app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth)); +app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter()); // Ivanti archive routes — finding archive tracking for severity score drift -app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth)); +app.use('/api/ivanti/archive', createIvantiArchiveRouter()); // Ivanti FP workflow routes — submit False Positive workflows to Ivanti API -app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth)); +app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter()); // AEO compliance routes — xlsx upload, non-compliant item tracking, notes -app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup)); +app.use('/api/compliance', createComplianceRouter(upload)); // Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges -app.use('/api/atlas', createAtlasRouter(db, requireAuth)); +app.use('/api/atlas', createAtlasRouter()); // Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create) -app.use('/api/jira-tickets', createJiraTicketsRouter(db)); +app.use('/api/jira-tickets', createJiraTicketsRouter()); // CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search -app.use('/api/card', createCardApiRouter(db, requireAuth)); +app.use('/api/card', createCardApiRouter()); // Feedback routes — bug reports and feature requests to GitLab -app.use('/api/feedback', createFeedbackRouter(db, requireAuth)); +app.use('/api/feedback', createFeedbackRouter()); // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) -app.get('/api/cves', requireAuth(db), (req, res) => { +app.get('/api/cves', requireAuth(), async (req, res) => { const { search, vendor, severity, status } = req.query; - + let query = ` SELECT c.*, COUNT(d.id) as document_count FROM cves c LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor WHERE 1=1 `; - + const params = []; - + let paramIndex = 1; + if (search) { - query += ` AND (c.cve_id LIKE ? OR c.description LIKE ?)`; + query += ` AND (c.cve_id ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex + 1})`; params.push(`%${search}%`, `%${search}%`); + paramIndex += 2; } if (vendor && vendor !== 'All Vendors') { - query += ` AND c.vendor = ?`; + query += ` AND c.vendor = $${paramIndex++}`; params.push(vendor); } if (severity && severity !== 'All Severities') { - query += ` AND c.severity = ?`; + query += ` AND c.severity = $${paramIndex++}`; params.push(severity); } if (status) { - query += ` AND c.status = ?`; + query += ` AND c.status = $${paramIndex++}`; params.push(status); } - + query += ` GROUP BY c.id ORDER BY c.published_date DESC`; - - db.all(query, params, (err, rows) => { - if (err) { - console.error('Error fetching CVEs:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + + try { + const { rows } = await pool.query(query, params); res.json(rows); - }); + } catch (err) { + console.error('Error fetching CVEs:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Get distinct CVE IDs for NVD sync (authenticated users) -app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => { - db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => { - if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } +app.get('/api/cves/distinct-ids', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id'); res.json(rows.map(r => r.cve_id)); - }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users) -app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => { +app.get('/api/cves/check/:cveId', requireAuth(), async (req, res) => { const { cveId } = req.params; - + const query = ` SELECT c.*, COUNT(d.id) as total_documents, @@ -312,14 +287,12 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => { COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot FROM cves c LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor - WHERE c.cve_id = ? + WHERE c.cve_id = $1 GROUP BY c.id `; - db.all(query, [cveId], (err, rows) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query(query, [cveId]); if (!rows || rows.length === 0) { return res.json({ exists: false, @@ -334,49 +307,53 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => { vendor: row.vendor, severity: row.severity, status: row.status, - total_documents: row.total_documents, + total_documents: parseInt(row.total_documents), doc_types: { - email: row.has_email > 0, - screenshot: row.has_screenshot > 0 + email: parseInt(row.has_email) > 0, + screenshot: parseInt(row.has_screenshot) > 0 } })), addressed: true }); - }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users) -app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => { +app.get('/api/cves/:cveId/vendors', requireAuth(), async (req, res) => { const { cveId } = req.params; - - const query = ` - SELECT vendor, severity, status, description, published_date - FROM cves - WHERE cve_id = ? - ORDER BY vendor - `; - - db.all(query, [cveId], (err, rows) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } + + try { + const { rows } = await pool.query( + `SELECT vendor, severity, status, description, published_date + FROM cves + WHERE cve_id = $1 + ORDER BY vendor`, + [cveId] + ); res.json(rows); - }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Get tooltip data for a specific CVE (authenticated users) -app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => { +app.get('/api/cves/:cveId/tooltip', requireAuth(), async (req, res) => { const { cveId } = req.params; if (!CVE_ID_PATTERN.test(cveId)) { return res.status(400).json({ error: 'Invalid CVE ID format.' }); } - db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => { - if (err) { - console.error('Error fetching CVE tooltip:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } + try { + const { rows } = await pool.query( + 'SELECT cve_id, description, severity FROM cves WHERE cve_id = $1 LIMIT 1', + [cveId] + ); + const row = rows[0]; if (!row) { return res.json({ exists: false }); } @@ -385,22 +362,25 @@ app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => { description = description.substring(0, 300) + '\u2026'; } res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity }); - }); + } catch (err) { + console.error('Error fetching CVE tooltip:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Compliance export — reads from cve_document_status view -app.get('/api/cves/compliance', requireAuth(db), (req, res) => { - db.all('SELECT * FROM cve_document_status ORDER BY cve_id, vendor', [], (err, rows) => { - if (err) { - console.error('Error fetching compliance data:', err); - return res.status(500).json({ error: 'Internal server error.' }); - } +app.get('/api/cves/compliance', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM cve_document_status ORDER BY cve_id, vendor'); res.json(rows); - }); + } catch (err) { + console.error('Error fetching compliance data:', err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin) -app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.post('/api/cves', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cve_id, vendor, severity, description, published_date } = req.body; // Input validation @@ -420,22 +400,15 @@ app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), ( return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' }); } - const query = ` - INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by) - VALUES (?, ?, ?, ?, ?, ?) - `; + try { + const { rows } = await pool.query( + `INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [cve_id, vendor, severity, description, published_date, req.user.id] + ); - db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) { - if (err) { - console.error('DATABASE ERROR:', err); - if (err.message.includes('UNIQUE constraint failed')) { - return res.status(409).json({ - error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.' - }); - } - return res.status(500).json({ error: 'Failed to create CVE entry.' }); - } - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_create', @@ -444,17 +417,26 @@ app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), ( details: { vendor, severity }, ipAddress: req.ip }); + res.json({ - id: this.lastID, + id: rows[0].id, cve_id, message: `CVE created successfully for vendor: ${vendor}` }); - }); + } catch (err) { + console.error('DATABASE ERROR:', err); + if (err.code === '23505') { // Postgres unique violation + return res.status(409).json({ + error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.' + }); + } + res.status(500).json({ error: 'Failed to create CVE entry.' }); + } }); // Update CVE status (editor or admin) -app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.patch('/api/cves/:cveId/status', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cveId } = req.params; const { status } = req.body; @@ -462,13 +444,13 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Sta return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` }); } - const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`; + try { + const result = await pool.query( + 'UPDATE cves SET status = $1, updated_at = NOW() WHERE cve_id = $2', + [status, cveId] + ); - db.run(query, [status, cveId], function(err) { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } - logAudit(db, { + logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_update_status', @@ -477,12 +459,16 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Sta details: { status }, ipAddress: req.ip }); - res.json({ message: 'Status updated successfully', changes: this.changes }); - }); + + res.json({ message: 'Status updated successfully', changes: result.rowCount }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Bulk sync CVE data from NVD (editor or admin) -app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.post('/api/cves/nvd-sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { updates } = req.body; if (!Array.isArray(updates) || updates.length === 0) { return res.status(400).json({ error: 'No updates provided' }); @@ -490,69 +476,60 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_ let updated = 0; const errors = []; - let completed = 0; - db.serialize(() => { - updates.forEach((entry) => { - const fields = []; - const values = []; - if (entry.description !== null && entry.description !== undefined) { - fields.push('description = ?'); - values.push(entry.description); - } - if (entry.severity !== null && entry.severity !== undefined) { - fields.push('severity = ?'); - values.push(entry.severity); - } - if (entry.published_date !== null && entry.published_date !== undefined) { - fields.push('published_date = ?'); - values.push(entry.published_date); - } - if (fields.length === 0) { - completed++; - if (completed === updates.length) sendResponse(); - return; - } - fields.push('updated_at = CURRENT_TIMESTAMP'); - values.push(entry.cve_id); + for (const entry of updates) { + const fields = []; + const values = []; + let paramIndex = 1; - db.run( - `UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`, - values, - function(err) { - if (err) { - console.error('NVD sync update error:', err); - errors.push({ cve_id: entry.cve_id, error: 'Update failed' }); - } else { - updated += this.changes; - } - completed++; - if (completed === updates.length) sendResponse(); - } + if (entry.description !== null && entry.description !== undefined) { + fields.push(`description = $${paramIndex++}`); + values.push(entry.description); + } + if (entry.severity !== null && entry.severity !== undefined) { + fields.push(`severity = $${paramIndex++}`); + values.push(entry.severity); + } + if (entry.published_date !== null && entry.published_date !== undefined) { + fields.push(`published_date = $${paramIndex++}`); + values.push(entry.published_date); + } + if (fields.length === 0) continue; + + fields.push('updated_at = NOW()'); + values.push(entry.cve_id); + + try { + const result = await pool.query( + `UPDATE cves SET ${fields.join(', ')} WHERE cve_id = $${paramIndex}`, + values ); - }); + updated += result.rowCount; + } catch (err) { + console.error('NVD sync update error:', err); + errors.push({ cve_id: entry.cve_id, error: 'Update failed' }); + } + } + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'cve_nvd_sync', + entityType: 'cve', + entityId: null, + details: { count: updated, cve_ids: updates.map(u => u.cve_id) }, + ipAddress: req.ip }); - function sendResponse() { - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'cve_nvd_sync', - entityType: 'cve', - entityId: null, - details: { count: updated, cve_ids: updates.map(u => u.cve_id) }, - ipAddress: req.ip - }); - const result = { message: 'NVD sync completed', updated }; - if (errors.length > 0) result.errors = errors; - res.json(result); - } + const result = { message: 'NVD sync completed', updated }; + if (errors.length > 0) result.errors = errors; + res.json(result); }); // ========== CVE EDIT & DELETE ENDPOINTS ========== // Edit single CVE entry (editor or admin) -app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.put('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { cve_id, vendor, severity, description, published_date, status } = req.body; @@ -576,9 +553,10 @@ app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User') return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` }); } - // Fetch existing row first - db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => { - if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } + try { + // Fetch existing row first + const { rows: existingRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]); + const existing = existingRows[0]; if (!existing) return res.status(404).json({ error: 'CVE entry not found' }); const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status }; @@ -588,120 +566,110 @@ app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User') const cveIdChanged = newCveId !== existing.cve_id; const vendorChanged = newVendor !== existing.vendor; - const doUpdate = () => { - // Build dynamic SET clause - const fields = []; - const values = []; - if (cve_id !== undefined) { fields.push('cve_id = ?'); values.push(cve_id); } - if (vendor !== undefined) { fields.push('vendor = ?'); values.push(vendor); } - if (severity !== undefined) { fields.push('severity = ?'); values.push(severity); } - if (description !== undefined) { fields.push('description = ?'); values.push(description); } - if (published_date !== undefined) { fields.push('published_date = ?'); values.push(published_date); } - if (status !== undefined) { fields.push('status = ?'); values.push(status); } - - if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); - - fields.push('updated_at = CURRENT_TIMESTAMP'); - values.push(id); - - db.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) { - if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); } - - const after = { - cve_id: newCveId, vendor: newVendor, - severity: severity !== undefined ? severity : existing.severity, - description: description !== undefined ? description : existing.description, - published_date: published_date !== undefined ? published_date : existing.published_date, - status: status !== undefined ? status : existing.status - }; - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'cve_edit', - entityType: 'cve', - entityId: newCveId, - details: { before, after }, - ipAddress: req.ip - }); - - res.json({ message: 'CVE updated successfully', changes: this.changes }); - }); - }; - if (cveIdChanged || vendorChanged) { // Check UNIQUE constraint - db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => { - if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); } - if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' }); + const { rows: conflictRows } = await pool.query( + 'SELECT id FROM cves WHERE cve_id = $1 AND vendor = $2 AND id != $3', + [newCveId, newVendor, id] + ); + if (conflictRows.length > 0) { + return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' }); + } - // Rename document directory (with path traversal prevention) - const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor)); - const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor)); + // Rename document directory (with path traversal prevention) + const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor)); + const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor)); - if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) { - return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' }); + if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) { + return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' }); + } + + if (fs.existsSync(oldDir)) { + const newParent = path.join('uploads', newCveId); + if (!fs.existsSync(newParent)) { + fs.mkdirSync(newParent, { recursive: true }); } + fs.renameSync(oldDir, newDir); - if (fs.existsSync(oldDir)) { - const newParent = path.join('uploads', newCveId); - if (!fs.existsSync(newParent)) { - fs.mkdirSync(newParent, { recursive: true }); - } - fs.renameSync(oldDir, newDir); - - // Clean up old cve_id directory if empty - const oldParent = path.join('uploads', existing.cve_id); - if (fs.existsSync(oldParent)) { - const remaining = fs.readdirSync(oldParent); - if (remaining.length === 0) fs.rmdirSync(oldParent); - } + // Clean up old cve_id directory if empty + const oldParent = path.join('uploads', existing.cve_id); + if (fs.existsSync(oldParent)) { + const remaining = fs.readdirSync(oldParent); + if (remaining.length === 0) fs.rmdirSync(oldParent); } + } - // Update documents table - file paths - db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => { - if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); } + // Update documents table - file paths + const { rows: docs } = await pool.query( + 'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2', + [existing.cve_id, existing.vendor] + ); - const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor); - const newPrefix = path.join('uploads', newCveId, newVendor); + const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor); + const newPrefix = path.join('uploads', newCveId, newVendor); - let docUpdated = 0; - const totalDocs = docs.length; - - const finishDocUpdate = () => { - if (docUpdated >= totalDocs) doUpdate(); - }; - - if (totalDocs === 0) { - doUpdate(); - } else { - docs.forEach((doc) => { - const newFilePath = doc.file_path.replace(oldPrefix, newPrefix); - db.run('UPDATE documents SET cve_id = ?, vendor = ?, file_path = ? WHERE id = ?', - [newCveId, newVendor, newFilePath, doc.id], - (docUpdateErr) => { - if (docUpdateErr) console.error('Error updating document:', docUpdateErr); - docUpdated++; - finishDocUpdate(); - } - ); - }); - } - }); - }); - } else { - doUpdate(); + for (const doc of docs) { + const newFilePath = doc.file_path.replace(oldPrefix, newPrefix); + await pool.query( + 'UPDATE documents SET cve_id = $1, vendor = $2, file_path = $3 WHERE id = $4', + [newCveId, newVendor, newFilePath, doc.id] + ); + } } - }); + + // Build dynamic SET clause + const fields = []; + const values = []; + let paramIndex = 1; + if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id); } + if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor); } + if (severity !== undefined) { fields.push(`severity = $${paramIndex++}`); values.push(severity); } + if (description !== undefined) { fields.push(`description = $${paramIndex++}`); values.push(description); } + if (published_date !== undefined) { fields.push(`published_date = $${paramIndex++}`); values.push(published_date); } + if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); } + + if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); + + fields.push('updated_at = NOW()'); + values.push(id); + + const updateResult = await pool.query( + `UPDATE cves SET ${fields.join(', ')} WHERE id = $${paramIndex}`, + values + ); + + const after = { + cve_id: newCveId, vendor: newVendor, + severity: severity !== undefined ? severity : existing.severity, + description: description !== undefined ? description : existing.description, + published_date: published_date !== undefined ? published_date : existing.published_date, + status: status !== undefined ? status : existing.status + }; + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'cve_edit', + entityType: 'cve', + entityId: newCveId, + details: { before, after }, + ipAddress: req.ip + }); + + res.json({ message: 'CVE updated successfully', changes: updateResult.rowCount }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route -app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.delete('/api/cves/by-cve-id/:cveId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cveId } = req.params; - // Get all rows for this CVE ID to know what we're deleting - db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => { - if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } + try { + // Get all rows for this CVE ID to know what we're deleting + const { rows } = await pool.query('SELECT * FROM cves WHERE cve_id = $1', [cveId]); if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' }); // Ownership check: Standard_User can only delete CVEs they created @@ -712,180 +680,175 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', } // Cascade impact check for Standard_User - // Query all three cascade-deleted resource types in parallel - db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => { - if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); } + const { rows: archerTickets } = await pool.query( + 'SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = $1', + [cveId] + ); - db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => { - // If jira_tickets table doesn't exist yet, treat as empty - if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { - jiraTickets = []; - } else if (jiraErr) { - console.error(jiraErr); - return res.status(500).json({ error: 'Internal server error.' }); + let jiraTickets = []; + try { + const jiraResult = await pool.query( + 'SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = $1', + [cveId] + ); + jiraTickets = jiraResult.rows; + } catch (jiraErr) { + // If table doesn't exist yet, treat as empty + if (!jiraErr.message.includes('does not exist')) throw jiraErr; + } + + const { rows: docs } = await pool.query( + 'SELECT id, name, type FROM documents WHERE cve_id = $1', + [cveId] + ); + + const allTickets = [ + ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), + ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) + ]; + + // If no tickets at all, no compliance linkage possible — return cascade info + if (allTickets.length === 0) { + return res.json({ + cascade_impact: { + archer_tickets: [], + jira_tickets: [], + documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })), + blocked: false, + blocked_reason: null } - - db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => { - if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); } - - const allTickets = [ - ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), - ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) - ]; - - // If no tickets at all, no compliance linkage possible — return cascade info - if (allTickets.length === 0) { - return res.json({ - cascade_impact: { - archer_tickets: [], - jira_tickets: [], - documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })), - blocked: false, - blocked_reason: null - } - }); - } - - // Check compliance linkage for each ticket - // A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id - // appears in active compliance_items extra_json - const likeConditions = []; - const likeParams = []; - for (const t of allTickets) { - likeConditions.push('ci.extra_json LIKE ?'); - likeParams.push(`%${t.key}%`); - } - // Also check if the CVE ID itself appears in compliance extra_json - likeConditions.push('ci.extra_json LIKE ?'); - likeParams.push(`%${cveId}%`); - - db.all( - `SELECT ci.id, ci.extra_json, cu.report_date - FROM compliance_items ci - JOIN compliance_uploads cu ON ci.upload_id = cu.id - WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`, - likeParams, - (compErr, compLinks) => { - // If compliance_items table doesn't exist yet, treat as no linkage - if (compErr && compErr.message && compErr.message.includes('no such table')) { - compLinks = []; - } else if (compErr) { - console.error(compErr); - return res.status(500).json({ error: 'Internal server error.' }); - } - - // Determine which tickets are compliance-linked by checking extra_json matches - const linkedTicketKeys = new Set(); - for (const cl of (compLinks || [])) { - const json = cl.extra_json || ''; - for (const t of allTickets) { - if (json.includes(t.key)) { - linkedTicketKeys.add(`${t.source}:${t.id}`); - } - } - // If CVE ID itself is in compliance data, all tickets are considered linked - if (json.includes(cveId)) { - for (const t of allTickets) { - linkedTicketKeys.add(`${t.source}:${t.id}`); - } - } - } - - const archerTicketsResult = (archerTickets || []).map(t => ({ - id: t.id, - exc_number: t.exc_number, - compliance_linked: linkedTicketKeys.has(`archer:${t.id}`) - })); - - const jiraTicketsResult = (jiraTickets || []).map(t => ({ - id: t.id, - ticket_key: t.ticket_key, - compliance_linked: linkedTicketKeys.has(`jira:${t.id}`) - })); - - const documentsResult = (docs || []).map(d => ({ - id: d.id, - name: d.name, - type: d.type - })); - - const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked) - || jiraTicketsResult.some(t => t.compliance_linked); - - if (hasComplianceLink) { - const blockedArcher = archerTicketsResult.find(t => t.compliance_linked); - const blockedJira = jiraTicketsResult.find(t => t.compliance_linked); - const blockedLabel = blockedArcher - ? `Archer ticket ${blockedArcher.exc_number}` - : `JIRA ticket ${blockedJira.ticket_key}`; - return res.status(403).json({ - error: 'CVE deletion blocked: associated ticket linked to compliance report', - cascade_impact: { - archer_tickets: archerTicketsResult, - jira_tickets: jiraTicketsResult, - documents: documentsResult, - blocked: true, - blocked_reason: `${blockedLabel} is linked to a compliance report` - } - }); - } - - // Not blocked — return cascade impact for frontend warning - return res.json({ - cascade_impact: { - archer_tickets: archerTicketsResult, - jira_tickets: jiraTicketsResult, - documents: documentsResult, - blocked: false, - blocked_reason: null - } - }); - } - ); - }); }); + } + + // Check compliance linkage for each ticket + const likeConditions = []; + const likeParams = []; + let pIdx = 1; + for (const t of allTickets) { + likeConditions.push(`ci.extra_json LIKE $${pIdx++}`); + likeParams.push(`%${t.key}%`); + } + // Also check if the CVE ID itself appears in compliance extra_json + likeConditions.push(`ci.extra_json LIKE $${pIdx++}`); + likeParams.push(`%${cveId}%`); + + let compLinks = []; + try { + const compResult = await pool.query( + `SELECT ci.id, ci.extra_json, cu.report_date + FROM compliance_items ci + JOIN compliance_uploads cu ON ci.upload_id = cu.id + WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`, + likeParams + ); + compLinks = compResult.rows; + } catch (compErr) { + if (!compErr.message.includes('does not exist')) throw compErr; + } + + // Determine which tickets are compliance-linked + const linkedTicketKeys = new Set(); + for (const cl of compLinks) { + const json = cl.extra_json || ''; + for (const t of allTickets) { + if (json.includes(t.key)) { + linkedTicketKeys.add(`${t.source}:${t.id}`); + } + } + if (json.includes(cveId)) { + for (const t of allTickets) { + linkedTicketKeys.add(`${t.source}:${t.id}`); + } + } + } + + const archerTicketsResult = (archerTickets || []).map(t => ({ + id: t.id, + exc_number: t.exc_number, + compliance_linked: linkedTicketKeys.has(`archer:${t.id}`) + })); + + const jiraTicketsResult = (jiraTickets || []).map(t => ({ + id: t.id, + ticket_key: t.ticket_key, + compliance_linked: linkedTicketKeys.has(`jira:${t.id}`) + })); + + const documentsResult = (docs || []).map(d => ({ + id: d.id, + name: d.name, + type: d.type + })); + + const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked) + || jiraTicketsResult.some(t => t.compliance_linked); + + if (hasComplianceLink) { + const blockedArcher = archerTicketsResult.find(t => t.compliance_linked); + const blockedJira = jiraTicketsResult.find(t => t.compliance_linked); + const blockedLabel = blockedArcher + ? `Archer ticket ${blockedArcher.exc_number}` + : `JIRA ticket ${blockedJira.ticket_key}`; + return res.status(403).json({ + error: 'CVE deletion blocked: associated ticket linked to compliance report', + cascade_impact: { + archer_tickets: archerTicketsResult, + jira_tickets: jiraTicketsResult, + documents: documentsResult, + blocked: true, + blocked_reason: `${blockedLabel} is linked to a compliance report` + } + }); + } + + // Not blocked — return cascade impact for frontend warning + return res.json({ + cascade_impact: { + archer_tickets: archerTicketsResult, + jira_tickets: jiraTicketsResult, + documents: documentsResult, + blocked: false, + blocked_reason: null + } }); - return; // Exit early — Standard_User flow handled above } - // Admin flow: proceed directly with deletion (no cascade check) - // Delete all documents from DB - db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => { - if (docErr) console.error('Error deleting documents:', docErr); + // Admin flow: proceed directly with deletion + await pool.query('DELETE FROM documents WHERE cve_id = $1', [cveId]); + const deleteResult = await pool.query('DELETE FROM cves WHERE cve_id = $1', [cveId]); - // Delete all CVE rows - db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) { - if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); } + // Remove upload directory (with path traversal prevention) + const safeCveId = sanitizePathSegment(cveId); + const cveDir = path.join('uploads', safeCveId); + if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) { + fs.rmSync(cveDir, { recursive: true, force: true }); + } - // Remove upload directory (with path traversal prevention) - const safeCveId = sanitizePathSegment(cveId); - const cveDir = path.join('uploads', safeCveId); - if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) { - fs.rmSync(cveDir, { recursive: true, force: true }); - } - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'cve_delete', - entityType: 'cve', - entityId: cveId, - details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length }, - ipAddress: req.ip - }); - - res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: this.changes }); - }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'cve_delete', + entityType: 'cve', + entityId: cveId, + details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length }, + ipAddress: req.ip }); - }); + + res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: deleteResult.rowCount }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); + // Delete single CVE vendor entry (editor or admin) -app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { +app.delete('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; - db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => { - if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); } + try { + const { rows: cveRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]); + const cve = cveRows[0]; if (!cve) return res.status(404).json({ error: 'CVE entry not found' }); // Ownership check: Standard_User can only delete CVEs they created @@ -895,137 +858,140 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use // Cascade/compliance check for Standard_User if (req.user.group === 'Standard_User') { - return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => { - if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); } + const { rows: archerTickets } = await pool.query( + 'SELECT id, exc_number FROM archer_tickets WHERE cve_id = $1 AND vendor = $2', + [cve.cve_id, cve.vendor] + ); - db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => { - if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; } - else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); } + let jiraTickets = []; + try { + const jiraResult = await pool.query( + 'SELECT id, ticket_key FROM jira_tickets WHERE cve_id = $1 AND vendor = $2', + [cve.cve_id, cve.vendor] + ); + jiraTickets = jiraResult.rows; + } catch (jiraErr) { + if (!jiraErr.message.includes('does not exist')) throw jiraErr; + } - const allTickets = [ - ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), - ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) - ]; + const allTickets = [ + ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), + ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) + ]; - if (allTickets.length === 0) { - return doSingleCveDelete(req, res, id, cve); - } + if (allTickets.length > 0) { + const likeConditions = allTickets.map((_, i) => `ci.extra_json LIKE $${i + 1}`); + const likeParams = allTickets.map(t => `%${t.key}%`); - const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?'); - const likeParams = allTickets.map(t => `%${t.key}%`); - - db.all( + let compLinks = []; + try { + const compResult = await pool.query( `SELECT ci.id, ci.extra_json FROM compliance_items ci JOIN compliance_uploads cu ON ci.upload_id = cu.id WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`, - likeParams, - (compErr, compLinks) => { - if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; } - else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); } - - const hasLink = (compLinks || []).some(cl => { - const json = cl.extra_json || ''; - return allTickets.some(t => json.includes(t.key)); - }); - - if (hasLink) { - return res.status(403).json({ - error: 'CVE deletion blocked: associated ticket linked to compliance report', - cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' } - }); - } - - return doSingleCveDelete(req, res, id, cve); - } + likeParams ); + compLinks = compResult.rows; + } catch (compErr) { + if (!compErr.message.includes('does not exist')) throw compErr; + } + + const hasLink = compLinks.some(cl => { + const json = cl.extra_json || ''; + return allTickets.some(t => json.includes(t.key)); }); + + if (hasLink) { + return res.status(403).json({ + error: 'CVE deletion blocked: associated ticket linked to compliance report', + cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' } + }); + } + } + } + + // Proceed with deletion + // Delete associated documents from DB and disk + const { rows: docs } = await pool.query( + 'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2', + [cve.cve_id, cve.vendor] + ); + + // Delete document files from disk (with path traversal prevention) + if (docs && docs.length > 0) { + docs.forEach(doc => { + if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) { + fs.unlinkSync(doc.file_path); + } }); } - doSingleCveDelete(req, res, id, cve); - }); + // Delete documents from DB + await pool.query('DELETE FROM documents WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor]); - function doSingleCveDelete(req, res, id, cve) { - // Delete associated documents from DB - db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => { - if (docErr) console.error('Error fetching documents:', docErr); + // Delete CVE row + const deleteResult = await pool.query('DELETE FROM cves WHERE id = $1', [id]); - // Delete document files from disk (with path traversal prevention) - if (docs && docs.length > 0) { - docs.forEach(doc => { - if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) { - fs.unlinkSync(doc.file_path); - } - }); - } + // Clean up directories (with path traversal prevention) + const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor)); + if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) { + fs.rmSync(safeVendorDir, { recursive: true, force: true }); + } + const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id)); + if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) { + const remaining = fs.readdirSync(safeCveDir); + if (remaining.length === 0) fs.rmdirSync(safeCveDir); + } - // Delete documents from DB - db.run('DELETE FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (delDocErr) => { - if (delDocErr) console.error('Error deleting documents from DB:', delDocErr); - - // Delete CVE row - db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) { - if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); } - - // Clean up directories (with path traversal prevention) - const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor)); - if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) { - fs.rmSync(safeVendorDir, { recursive: true, force: true }); - } - const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id)); - if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) { - const remaining = fs.readdirSync(safeCveDir); - if (remaining.length === 0) fs.rmdirSync(safeCveDir); - } - - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'cve_delete', - entityType: 'cve', - entityId: cve.cve_id, - details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity }, - ipAddress: req.ip - }); - - res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` }); - }); - }); + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'cve_delete', + entityType: 'cve', + entityId: cve.cve_id, + details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity }, + ipAddress: req.ip }); + + res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); } }); // ========== DOCUMENT ENDPOINTS ========== // Get documents for a CVE - FILTER BY VENDOR (authenticated users) -app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => { +app.get('/api/cves/:cveId/documents', requireAuth(), async (req, res) => { const { cveId } = req.params; - const { vendor } = req.query; // NEW: Optional vendor filter - - let query = `SELECT * FROM documents WHERE cve_id = ?`; - let params = [cveId]; - + const { vendor } = req.query; // Optional vendor filter + + let query = 'SELECT * FROM documents WHERE cve_id = $1'; + const params = [cveId]; + let paramIndex = 2; + if (vendor) { - query += ` AND vendor = ?`; + query += ` AND vendor = $${paramIndex++}`; params.push(vendor); } - - query += ` ORDER BY uploaded_at DESC`; - - db.all(query, params, (err, rows) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } + + query += ' ORDER BY uploaded_at DESC'; + + try { + const { rows } = await pool.query(query, params); res.json(rows); - }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin) -app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => { - upload.single('file')(req, res, (err) => { +app.post('/api/cves/:cveId/documents', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => { + upload.single('file')(req, res, async (err) => { if (err) { console.error('Upload error:', err.message); - // Show file validation errors to the user; hide other internal errors if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) { return res.status(400).json({ error: err.message }); } @@ -1034,7 +1000,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'S } return res.status(500).json({ error: 'File upload failed.' }); } - + const { cveId } = req.params; const { type, notes, vendor } = req.body; const file = req.file; @@ -1045,7 +1011,6 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'S } if (!vendor) { - // Clean up temp file if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(400).json({ error: 'Vendor is required' }); } @@ -1083,32 +1048,17 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'S // Move file from temp to final location fs.renameSync(file.path, finalPath); - const query = ` - INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `; - const fileSizeKB = (file.size / 1024).toFixed(2) + ' KB'; - db.run(query, [ - cveId, - vendor, - file.originalname, - type, - finalPath, - fileSizeKB, - file.mimetype, - notes - ], function(err) { - if (err) { - console.error('Document insert error:', err); - // If database insert fails, delete the file - if (fs.existsSync(finalPath)) { - fs.unlinkSync(finalPath); - } - return res.status(500).json({ error: 'Internal server error.' }); - } - logAudit(db, { + try { + const { rows } = await pool.query( + `INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [cveId, vendor, file.originalname, type, finalPath, fileSizeKB, file.mimetype, notes] + ); + + logAudit({ userId: req.user.id, username: req.user.username, action: 'document_upload', @@ -1117,66 +1067,74 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'S details: { vendor, type, filename: file.originalname }, ipAddress: req.ip }); + res.json({ - id: this.lastID, + id: rows[0].id, message: 'Document uploaded successfully', file: { name: file.originalname, size: fileSizeKB } }); - }); + } catch (dbErr) { + console.error('Document insert error:', dbErr); + // If database insert fails, delete the file + if (fs.existsSync(finalPath)) { + fs.unlinkSync(finalPath); + } + res.status(500).json({ error: 'Internal server error.' }); + } }); }); + // Delete document (admin only) -app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => { +app.delete('/api/documents/:id', requireAuth(), requireGroup('Admin'), async (req, res) => { const { id } = req.params; - // First get the file path to delete the actual file - db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } + try { + // First get the file path to delete the actual file + const { rows } = await pool.query('SELECT file_path FROM documents WHERE id = $1', [id]); + const row = rows[0]; // Only delete file if path is within uploads directory if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) { fs.unlinkSync(row.file_path); } - - db.run('DELETE FROM documents WHERE id = ?', [id], function(err) { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } - logAudit(db, { - userId: req.user.id, - username: req.user.username, - action: 'document_delete', - entityType: 'document', - entityId: id, - details: { file_path: row ? row.file_path : null }, - ipAddress: req.ip - }); - res.json({ message: 'Document deleted successfully' }); + + await pool.query('DELETE FROM documents WHERE id = $1', [id]); + + logAudit({ + userId: req.user.id, + username: req.user.username, + action: 'document_delete', + entityType: 'document', + entityId: id, + details: { file_path: row ? row.file_path : null }, + ipAddress: req.ip }); - }); + + res.json({ message: 'Document deleted successfully' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // ========== UTILITY ENDPOINTS ========== // Get all vendors (authenticated users) -app.get('/api/vendors', requireAuth(db), (req, res) => { - const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`; - - db.all(query, [], (err, rows) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } +app.get('/api/vendors', requireAuth(), async (req, res) => { + try { + const { rows } = await pool.query('SELECT DISTINCT vendor FROM cves ORDER BY vendor'); res.json(rows.map(r => r.vendor)); - }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Get statistics (authenticated users) -app.get('/api/stats', requireAuth(db), (req, res) => { +app.get('/api/stats', requireAuth(), async (req, res) => { const query = ` SELECT COUNT(DISTINCT c.id) as total_cves, @@ -1189,12 +1147,13 @@ app.get('/api/stats', requireAuth(db), (req, res) => { LEFT JOIN cve_document_status cd ON c.cve_id = cd.cve_id `; - db.get(query, [], (err, row) => { - if (err) { - console.error(err); return res.status(500).json({ error: 'Internal server error.' }); - } - res.json(row); - }); + try { + const { rows } = await pool.query(query); + res.json(rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error.' }); + } }); // Start server