feat(postgres): migrate all route files from SQLite to pg pool

- All 16 route files now import pool from ../db directly
- Removed db parameter from all factory functions
- All callbacks replaced with async/await pool.query()
- All ? placeholders converted to $1, $2... numbered params
- datetime('now') → NOW(), INSERT OR IGNORE → ON CONFLICT DO NOTHING
- LIKE → ILIKE for case-insensitive searches
- Error detection: err.code === '23505' for unique violations
- server.js no longer passes pool/db/requireAuth to route factories
- Only ivantiFindings.js still receives pool (pending task 8 rewrite)
This commit is contained in:
Jordan Ramos
2026-05-06 11:44:17 -06:00
parent 845d843e71
commit 33927b150b
18 changed files with 2164 additions and 4432 deletions

View File

@@ -1,21 +1,19 @@
// Audit Log Helper // Audit Log Helper
// Fire-and-forget insert - never blocks the response // 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' const detailsStr = details && typeof details === 'object'
? JSON.stringify(details) ? JSON.stringify(details)
: details || null; : details || null;
db.run( pool.query(
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address) `INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null], [userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null]
(err) => { ).catch((err) => {
if (err) { console.error('Audit log error:', err.message);
console.error('Audit log error:', err.message); });
}
}
);
} }
module.exports = logAudit; module.exports = logAudit;

View File

@@ -1,7 +1,8 @@
// Authentication Middleware // Authentication Middleware
const pool = require('../db');
// Require authenticated user // Require authenticated user — no parameters needed, pool is imported directly
function requireAuth(db) { function requireAuth() {
return async (req, res, next) => { return async (req, res, next) => {
const sessionId = req.cookies?.session_id; const sessionId = req.cookies?.session_id;
@@ -10,19 +11,15 @@ function requireAuth(db) {
} }
try { try {
const session = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
`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
FROM sessions s JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`,
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, [sessionId]
[sessionId], );
(err, row) => {
if (err) reject(err); const session = rows[0];
else resolve(row);
}
);
});
if (!session) { if (!session) {
return res.status(401).json({ error: 'Session expired or invalid' }); return res.status(401).json({ error: 'Session expired or invalid' });

View File

@@ -1,5 +1,6 @@
// routes/archerTickets.js // routes/archerTickets.js
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
@@ -13,42 +14,43 @@ function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
} }
function createArcherTicketsRouter(db) { function createArcherTicketsRouter() {
const router = express.Router(); const router = express.Router();
// Get all Archer tickets (with optional filters) // 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; const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM archer_tickets WHERE 1=1'; let query = 'SELECT * FROM archer_tickets WHERE 1=1';
const params = []; const params = [];
let paramIndex = 1;
if (cve_id) { if (cve_id) {
query += ' AND cve_id = ?'; query += ` AND cve_id = $${paramIndex++}`;
params.push(cve_id); params.push(cve_id);
} }
if (vendor) { if (vendor) {
query += ' AND vendor = ?'; query += ` AND vendor = $${paramIndex++}`;
params.push(vendor); params.push(vendor);
} }
if (status) { if (status) {
query += ' AND status = ?'; query += ` AND status = $${paramIndex++}`;
params.push(status); params.push(status);
} }
query += ' ORDER BY created_at DESC'; query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => { try {
if (err) { const { rows } = await pool.query(query, params);
console.error('Error fetching Archer tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows); res.json(rows);
}); } catch (err) {
console.error('Error fetching Archer tickets:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
// Create Archer ticket // 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; const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation // Validation
@@ -73,38 +75,38 @@ function createArcherTicketsRouter(db) {
const validatedStatus = status || 'Draft'; const validatedStatus = status || 'Draft';
db.run( try {
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id], VALUES ($1, $2, $3, $4, $5, $6)
function(err) { RETURNING id`,
if (err) { [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id]
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.' });
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
action: 'CREATE_ARCHER_TICKET', action: 'CREATE_ARCHER_TICKET',
entityType: 'archer_ticket', entityType: 'archer_ticket',
entityId: String(this.lastID), entityId: String(rows[0].id),
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
message: 'Archer ticket created successfully' 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 // 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 { id } = req.params;
const { exc_number, archer_url, status } = req.body; 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.' }); return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
} }
// Get existing ticket try {
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
if (err) { const existing = rows[0];
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
const updates = []; const updates = [];
const params = []; const params = [];
let paramIndex = 1;
if (exc_number !== undefined) { if (exc_number !== undefined) {
updates.push('exc_number = ?'); updates.push(`exc_number = $${paramIndex++}`);
params.push(exc_number.trim()); params.push(exc_number.trim());
} }
if (archer_url !== undefined) { if (archer_url !== undefined) {
updates.push('archer_url = ?'); updates.push(`archer_url = $${paramIndex++}`);
params.push(archer_url || null); params.push(archer_url || null);
} }
if (status !== undefined) { if (status !== undefined) {
updates.push('status = ?'); updates.push(`status = $${paramIndex++}`);
params.push(status); params.push(status);
} }
@@ -154,73 +154,47 @@ function createArcherTicketsRouter(db) {
return res.status(400).json({ error: 'No fields to update.' }); return res.status(400).json({ error: 'No fields to update.' });
} }
updates.push('updated_at = CURRENT_TIMESTAMP'); updates.push('updated_at = NOW()');
params.push(id); params.push(id);
db.run( const result = await pool.query(
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`, `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
params, 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 });
}
); );
});
});
// Helper: perform the actual Archer ticket deletion logAudit({
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, {
userId: req.user.id, userId: req.user.id,
action: 'DELETE_ARCHER_TICKET', action: 'UPDATE_ARCHER_TICKET',
entityType: 'archer_ticket', entityType: 'archer_ticket',
entityId: String(id), entityId: String(id),
details: { deleted: ticket }, details: { before: existing, changes: req.body },
ipAddress: req.ip 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 // 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; const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
// Admin bypasses all delete restrictions // Admin bypasses all delete restrictions
if (req.user.group === 'Admin') { if (req.user.group === 'Admin') {
return performArcherDelete(db, req, res, id, ticket); return performArcherDelete();
} }
// Standard_User: ownership check // Standard_User: ownership check
@@ -230,53 +204,63 @@ function createArcherTicketsRouter(db) {
// Standard_User: compliance linkage check // Standard_User: compliance linkage check
const excNumber = ticket.exc_number; const excNumber = ticket.exc_number;
db.all( try {
`SELECT ci.id, ci.extra_json const { rows: compLinks } = await pool.query(
FROM compliance_items ci `SELECT ci.id, ci.extra_json
JOIN compliance_uploads cu ON ci.upload_id = cu.id FROM compliance_items ci
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, JOIN compliance_uploads cu ON ci.upload_id = cu.id
[`%${excNumber}%`], WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
(compErr, compLinks) => { [`%${excNumber}%`]
// 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.' });
}
const isLinked = (compLinks || []).some(cl => { const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || ''; const json = cl.extra_json || '';
return json.includes(excNumber); return json.includes(excNumber);
}); });
if (isLinked) { if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performArcherDelete(db, req, res, id, ticket);
} }
); } 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 // 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(), async (req, res) => {
router.get('/status-trend', requireAuth(db), (req, res) => { try {
db.all( const { rows } = await pool.query(
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count `SELECT DATE(created_at) AS date, status, COUNT(*) AS count
FROM archer_tickets FROM archer_tickets
GROUP BY DATE(created_at), status GROUP BY DATE(created_at), status
ORDER BY date ASC`, ORDER BY date ASC`
[], );
(err, rows) => { res.json({ statusTrend: rows });
if (err) { } catch (err) {
console.error('Error fetching Archer status trend:', err); console.error('Error fetching Archer status trend:', err);
return res.status(500).json({ error: 'Internal server error.' }); res.status(500).json({ error: 'Internal server error.' });
} }
res.json({ statusTrend: rows });
}
);
}); });
return router; return router;

View File

@@ -1,9 +1,10 @@
// Atlas InfoSec Action Plans Routes // 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. // for fast badge rendering on the ReportingPage.
const express = require('express'); 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 logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); 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 VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; 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) { function syncLog(msg) {
const line = `${new Date().toISOString()} ${msg}\n`; const line = `${new Date().toISOString()} ${msg}\n`;
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ } try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
console.log(msg); 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 // Pure aggregation function — exported for testability
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -55,7 +35,7 @@ function aggregateAtlasMetrics(rows) {
}; };
for (const row of 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++; result.hostsWithPlans++;
} else { } else {
result.hostsWithoutPlans++; result.hostsWithoutPlans++;
@@ -65,7 +45,6 @@ function aggregateAtlasMetrics(rows) {
try { try {
plans = JSON.parse(row.plans_json); plans = JSON.parse(row.plans_json);
} catch (e) { } catch (e) {
// Invalid JSON — skip plan details for this row
continue; continue;
} }
@@ -73,11 +52,9 @@ function aggregateAtlasMetrics(rows) {
for (const plan of plans) { for (const plan of plans) {
result.totalPlans++; result.totalPlans++;
if (plan.plan_type) { if (plan.plan_type) {
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1; result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
} }
if (plan.status) { if (plan.status) {
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1; result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
} }
@@ -90,28 +67,17 @@ function aggregateAtlasMetrics(rows) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createAtlasRouter(db, requireAuth) { function createAtlasRouter() {
const router = express.Router(); const router = express.Router();
// -----------------------------------------------------------------------
// GET /metrics // GET /metrics
// Return aggregated Atlas metrics for chart rendering. router.get('/metrics', requireAuth(), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 { try {
const rows = await dbAll(db, const { rows } = await pool.query(
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
); );
const metrics = aggregateAtlasMetrics(rows); const metrics = aggregateAtlasMetrics(rows);
@@ -122,23 +88,14 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// GET /status // GET /status
// Return all cached Atlas rows for badge rendering. router.get('/status', requireAuth(), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 { 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` `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
); );
res.json(rows); res.json(rows);
@@ -148,49 +105,23 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /sync // POST /sync
// Sync Atlas action plan data for all hosts found in the Ivanti cache. router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 { try {
// 1. Read Ivanti findings cache and extract unique non-null hostIds // Read Ivanti findings and extract unique non-null hostIds
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`); const { rows: findingsRows } = await pool.query(
if (!cacheRow || !cacheRow.findings_json) { `SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
return res.json({ synced: 0, withPlans: 0, failed: 0 }); );
} const hostIds = findingsRows.map(r => r.host_id);
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];
if (hostIds.length === 0) { if (hostIds.length === 0) {
return res.json({ synced: 0, withPlans: 0, failed: 0 }); return res.json({ synced: 0, withPlans: 0, failed: 0 });
} }
// 2. Process hosts in batches of 5 concurrent requests
let synced = 0; let synced = 0;
let withPlans = 0; let withPlans = 0;
let failed = 0; let failed = 0;
@@ -219,7 +150,6 @@ function createAtlasRouter(db, requireAuth) {
let activePlans = []; let activePlans = [];
try { try {
const parsed = JSON.parse(result.body); const parsed = JSON.parse(result.body);
// Atlas returns { active: [...], inactive: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
activePlans = Array.isArray(parsed.active) ? parsed.active : []; activePlans = Array.isArray(parsed.active) ? parsed.active : [];
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : []; const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
@@ -233,30 +163,24 @@ function createAtlasRouter(db, requireAuth) {
activePlans = []; activePlans = [];
} }
// Badge counts only active plans — inactive are historical
const planCount = activePlans.length; const planCount = activePlans.length;
const hasActionPlan = planCount > 0 ? 1 : 0; const hasActionPlan = planCount > 0;
console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`);
try { try {
// If Atlas returns 0 plans but we have a recent optimistic if (!hasActionPlan) {
// entry (from bulk creation within the last 10 minutes), const { rows: existingRows } = await pool.query(
// keep the optimistic value — Atlas's GET may lag behind. `SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`,
if (hasActionPlan === 0) {
const existing = await dbGet(db,
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`,
[hostId] [hostId]
); );
if (existing && existing.has_action_plan === 1) { const existing = existingRows[0];
if (existing && existing.has_action_plan === true) {
let existingPlans = []; let existingPlans = [];
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {} try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create'); const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
if (hasBulkStub) { 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; const TEN_MINUTES = 10 * 60 * 1000;
if (ageMs < TEN_MINUTES) { if (ageMs < TEN_MINUTES) {
console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`);
synced++; synced++;
withPlans++; withPlans++;
continue; 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) `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 ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = excluded.has_action_plan, has_action_plan = EXCLUDED.has_action_plan,
plan_count = excluded.plan_count, plan_count = EXCLUDED.plan_count,
plans_json = excluded.plans_json, plans_json = EXCLUDED.plans_json,
synced_at = excluded.synced_at`, synced_at = EXCLUDED.synced_at`,
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)] [hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
); );
} catch (dbErr) { } catch (dbErr) {
@@ -283,13 +207,12 @@ function createAtlasRouter(db, requireAuth) {
if (hasActionPlan) withPlans++; if (hasActionPlan) withPlans++;
} else { } else {
failed++; 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({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_SYNC', action: 'ATLAS_SYNC',
@@ -306,18 +229,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// GET /hosts/:hostId/action-plans // GET /hosts/:hostId/action-plans
// Proxy to Atlas API — returns live action plan data for a single host. router.get('/hosts/:hostId/action-plans', requireAuth(), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 { try {
const result = await atlasGet('/hosts/' + hostId + '/action-plans'); const result = await atlasGet('/hosts/' + hostId + '/action-plans');
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
// Forward non-2xx Atlas responses to the client
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -354,22 +257,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// PUT /hosts/:hostId/action-plans // PUT /hosts/:hostId/action-plans
// Create a new action plan for a host. router.put('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 || {}; const { plan_type, commit_date } = req.body || {};
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) { 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(', ') }); return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
} }
if (!commit_date || !DATE_PATTERN.test(commit_date)) { 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' }); 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 { try {
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body); const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_CREATE_PLAN', action: 'ATLAS_CREATE_PLAN',
@@ -404,19 +291,11 @@ function createAtlasRouter(db, requireAuth) {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -425,20 +304,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// PATCH /hosts/:hostId/action-plans // PATCH /hosts/:hostId/action-plans
// Update an existing action plan for a host. router.patch('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 || {}; const { action_plan_id, updates } = req.body || {};
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') { 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' }); 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)) { if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ error: 'updates is required and must be an object' }); return res.status(400).json({ error: 'updates is required and must be an object' });
} }
@@ -461,7 +326,7 @@ function createAtlasRouter(db, requireAuth) {
try { try {
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body); const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_UPDATE_PLAN', action: 'ATLAS_UPDATE_PLAN',
@@ -473,19 +338,11 @@ function createAtlasRouter(db, requireAuth) {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -494,41 +351,24 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /hosts/bulk-action-plans // POST /hosts/bulk-action-plans
// Create action plans for multiple hosts at once. router.post('/hosts/bulk-action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 || {}; const { host_ids, plan_type, commit_date } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) { 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' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
} }
for (const id of host_ids) { for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); 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)) { 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(', ') }); return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
} }
if (!commit_date || !DATE_PATTERN.test(commit_date)) { 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' }); 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) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
// Optimistically update local cache for all submitted hosts. // Optimistically update local cache
// 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.
for (const hid of host_ids) { for (const hid of host_ids) {
try { try {
const existing = await dbGet(db, const { rows: existingRows } = await pool.query(
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`, `SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
[hid] [hid]
); );
const existing = existingRows[0];
let existingPlans = []; let existingPlans = [];
if (existing && existing.plans_json) { 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 stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
const updatedPlans = [...existingPlans, stubPlan]; const updatedPlans = [...existingPlans, stubPlan];
const newCount = updatedPlans.length; 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) `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 ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = 1, has_action_plan = true,
plan_count = excluded.plan_count, plan_count = EXCLUDED.plan_count,
plans_json = excluded.plans_json, plans_json = EXCLUDED.plans_json,
synced_at = excluded.synced_at`, synced_at = EXCLUDED.synced_at`,
[hid, newCount, JSON.stringify(updatedPlans)] [hid, newCount, JSON.stringify(updatedPlans)]
); );
} catch (cacheErr) { } catch (cacheErr) {
@@ -579,7 +413,7 @@ function createAtlasRouter(db, requireAuth) {
} }
} }
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_BULK_CREATE_PLANS', action: 'ATLAS_BULK_CREATE_PLANS',
@@ -592,11 +426,7 @@ function createAtlasRouter(db, requireAuth) {
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -605,29 +435,16 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /hosts/vulnerabilities // POST /hosts/vulnerabilities
// Fetch active Ivanti vulnerabilities for multiple hosts from Atlas. router.post('/hosts/vulnerabilities', requireAuth(), async (req, res) => {
// 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) => {
if (!isConfigured) { 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.' }); 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 || {}; const { host_ids } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) { 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' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
} }
for (const id of host_ids) { for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); 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 { try {
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 }); 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) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {

View File

@@ -1,11 +1,13 @@
// Audit Log Routes (Admin only) // Audit Log Routes (Admin only)
const express = require('express'); 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(); const router = express.Router();
// All routes require Admin group // All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin')); router.use(requireAuth(), requireGroup('Admin'));
// Get paginated audit logs with filters // Get paginated audit logs with filters
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
@@ -24,25 +26,26 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
let where = []; let where = [];
let params = []; let params = [];
let paramIndex = 1;
if (user) { if (user) {
where.push('username LIKE ?'); where.push(`username ILIKE $${paramIndex++}`);
params.push(`%${user}%`); params.push(`%${user}%`);
} }
if (action) { if (action) {
where.push('action = ?'); where.push(`action = $${paramIndex++}`);
params.push(action); params.push(action);
} }
if (entityType) { if (entityType) {
where.push('entity_type = ?'); where.push(`entity_type = $${paramIndex++}`);
params.push(entityType); params.push(entityType);
} }
if (startDate) { if (startDate) {
where.push('created_at >= ?'); where.push(`created_at >= $${paramIndex++}`);
params.push(startDate); params.push(startDate);
} }
if (endDate) { if (endDate) {
where.push('created_at <= ?'); where.push(`created_at <= $${paramIndex++}`);
params.push(endDate + ' 23:59:59'); params.push(endDate + ' 23:59:59');
} }
@@ -50,36 +53,25 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
try { try {
// Get total count // Get total count
const countRow = await new Promise((resolve, reject) => { const countResult = await pool.query(
db.get( `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`, params
params, );
(err, row) => { const total = parseInt(countResult.rows[0].total);
if (err) reject(err);
else resolve(row);
}
);
});
// Get paginated results // Get paginated results
const rows = await new Promise((resolve, reject) => { const dataResult = await pool.query(
db.all( `SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]
[...params, pageSize, offset], );
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json({ res.json({
logs: rows, logs: dataResult.rows,
pagination: { pagination: {
page: parseInt(page), page: parseInt(page),
limit: pageSize, limit: pageSize,
total: countRow.total, total: total,
totalPages: Math.ceil(countRow.total / pageSize) totalPages: Math.ceil(total / pageSize)
} }
}); });
} catch (err) { } catch (err) {
@@ -91,16 +83,9 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
// Get distinct action types for filter dropdown // Get distinct action types for filter dropdown
router.get('/actions', async (req, res) => { router.get('/actions', async (req, res) => {
try { try {
const rows = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.all( 'SELECT DISTINCT action FROM audit_logs ORDER BY action'
'SELECT DISTINCT action FROM audit_logs ORDER BY action', );
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json(rows.map(r => r.action)); res.json(rows.map(r => r.action));
} catch (err) { } catch (err) {
console.error('Audit log actions error:', err); console.error('Audit log actions error:', err);

View File

@@ -3,6 +3,7 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
@@ -13,7 +14,7 @@ const loginLimiter = rateLimit({
message: { error: 'Too many login attempts. Please try again in 15 minutes.' } message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
}); });
function createAuthRouter(db, logAudit) { function createAuthRouter(logAudit) {
const router = express.Router(); const router = express.Router();
/** /**
@@ -39,19 +40,14 @@ function createAuthRouter(db, logAudit) {
try { try {
// Find user // Find user
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT * FROM users WHERE username = $1',
'SELECT * FROM users WHERE username = ?', [username]
[username], );
(err, row) => { const user = rows[0];
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) { if (!user) {
logAudit(db, { logAudit({
userId: null, userId: null,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -64,7 +60,7 @@ function createAuthRouter(db, logAudit) {
} }
if (!user.is_active) { if (!user.is_active) {
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -79,7 +75,7 @@ function createAuthRouter(db, logAudit) {
// Verify password // Verify password
const validPassword = await bcrypt.compare(password, user.password_hash); const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) { if (!validPassword) {
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -96,28 +92,16 @@ function createAuthRouter(db, logAudit) {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create session // Create session
await new Promise((resolve, reject) => { await pool.query(
db.run( 'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)',
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)', [sessionId, user.id, expiresAt.toISOString()]
[sessionId, user.id, expiresAt.toISOString()], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Update last login // Update last login
await new Promise((resolve, reject) => { await pool.query(
db.run( 'UPDATE users SET last_login = NOW() WHERE id = $1',
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]
[user.id], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Set cookie // Set cookie
res.cookie('session_id', sessionId, { res.cookie('session_id', sessionId, {
@@ -127,7 +111,7 @@ function createAuthRouter(db, logAudit) {
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
}); });
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: user.username, username: user.username,
action: 'login', action: 'login',
@@ -166,27 +150,31 @@ function createAuthRouter(db, logAudit) {
if (sessionId) { if (sessionId) {
// Look up user before deleting session // Look up user before deleting session
const session = await new Promise((resolve) => { let session = null;
db.get( try {
const { rows } = await pool.query(
`SELECT u.id as user_id, u.username FROM sessions s `SELECT u.id as user_id, u.username FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ?`, WHERE s.session_id = $1`,
[sessionId], [sessionId]
(err, row) => resolve(row || null)
); );
}); session = rows[0] || null;
} catch (err) {
// Non-critical — proceed with logout
}
// Delete session from database // Delete session from database
await new Promise((resolve) => { try {
db.run( await pool.query(
'DELETE FROM sessions WHERE session_id = ?', 'DELETE FROM sessions WHERE session_id = $1',
[sessionId], [sessionId]
() => resolve()
); );
}); } catch (err) {
// Non-critical — proceed with logout
}
if (session) { if (session) {
logAudit(db, { logAudit({
userId: session.user_id, userId: session.user_id,
username: session.username, username: session.username,
action: 'logout', action: 'logout',
@@ -221,19 +209,15 @@ function createAuthRouter(db, logAudit) {
} }
try { try {
const session = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active FROM sessions s
FROM sessions s JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`,
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, [sessionId]
[sessionId], );
(err, row) => {
if (err) reject(err); const session = rows[0];
else resolve(row);
}
);
});
if (!session) { if (!session) {
res.clearCookie('session_id'); res.clearCookie('session_id');
@@ -271,18 +255,14 @@ function createAuthRouter(db, logAudit) {
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie) * @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
* @returns {object} 500 - { error: 'Failed to fetch profile' } * @returns {object} 500 - { error: 'Failed to fetch profile' }
*/ */
router.get('/profile', requireAuth(db), async (req, res) => { router.get('/profile', requireAuth(), async (req, res) => {
try { try {
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1',
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?', [req.user.id]
[req.user.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user || !user.is_active) { if (!user || !user.is_active) {
res.clearCookie('session_id'); 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} 429 - { error: 'Too many password change attempts. Please try again later.' }
* @returns {object} 500 - { error: 'Failed to change password' } * @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; const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) { if (!currentPassword || !newPassword) {
@@ -340,16 +320,12 @@ function createAuthRouter(db, logAudit) {
try { try {
// Fetch user's password hash and active status // Fetch user's password hash and active status
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT password_hash, is_active FROM users WHERE id = $1',
'SELECT password_hash, is_active FROM users WHERE id = ?', [req.user.id]
[req.user.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user || !user.is_active) { if (!user || !user.is_active) {
return res.status(401).json({ error: 'Account is disabled' }); return res.status(401).json({ error: 'Account is disabled' });
@@ -363,18 +339,12 @@ function createAuthRouter(db, logAudit) {
// Hash new password and update // Hash new password and update
const newHash = await bcrypt.hash(newPassword, 10); const newHash = await bcrypt.hash(newPassword, 10);
await new Promise((resolve, reject) => { await pool.query(
db.run( 'UPDATE users SET password_hash = $1 WHERE id = $2',
'UPDATE users SET password_hash = ? WHERE id = ?', [newHash, req.user.id]
[newHash, req.user.id], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'password_change', action: 'password_change',
@@ -401,17 +371,9 @@ function createAuthRouter(db, logAudit) {
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' } * @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
* @returns {object} 500 - { error: 'Cleanup failed' } * @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 { try {
await new Promise((resolve, reject) => { await pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
db.run(
"DELETE FROM sessions WHERE expires_at < datetime('now')",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
res.json({ message: 'Expired sessions cleaned up' }); res.json({ message: 'Expired sessions cleaned up' });
} catch (err) { } catch (err) {
console.error('Session cleanup error:', err); console.error('Session cleanup error:', err);

View File

@@ -3,7 +3,8 @@
// the two-step update_token flow for mutations. // the two-step update_token flow for mutations.
const express = require('express'); 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 logAudit = require('../helpers/auditLog');
const { const {
isConfigured, isConfigured,
@@ -16,21 +17,6 @@ const {
redirectAsset, redirectAsset,
} = require('../helpers/cardApi'); } = 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 // Error classification — maps CARD API / token errors to client responses
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,7 +24,6 @@ function handleCardError(err, res) {
const msg = err.message || String(err); const msg = err.message || String(err);
console.error('[card-api]', msg); console.error('[card-api]', msg);
// Token endpoint errors (from acquireToken rejections)
if (msg.includes('Token acquisition failed')) { if (msg.includes('Token acquisition failed')) {
if (msg.includes('HTTP 401')) { if (msg.includes('HTTP 401')) {
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' }); 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')) { if (msg.includes('401')) {
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' }); 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.' }); 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 }); return res.status(502).json({ error: 'CARD API request failed.', details: msg });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createCardApiRouter(db, requireAuth) { function createCardApiRouter() {
const router = express.Router(); const router = express.Router();
// -------------------------------------------------------------------
// GET /status // GET /status
// Returns whether the CARD API integration is configured. router.get('/status', requireAuth(), (req, res) => {
// -------------------------------------------------------------------
router.get('/status', requireAuth(db), (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
configured: false,
error: 'CARD API is not configured.',
missingVars,
});
} }
res.json({ configured: true }); res.json({ configured: true });
}); });
// -------------------------------------------------------------------
// GET /teams // GET /teams
// Proxy CARD teams list. router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
try { try {
const result = await getTeams(); const result = await getTeams();
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// CARD API wraps teams in { teams: [...], response_time: ... }
const teams = Array.isArray(body) ? body : (body && body.teams) || []; const teams = Array.isArray(body) ? body : (body && body.teams) || [];
return res.json(teams); return res.json(teams);
} }
// Forward CARD error status
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// GET /teams/:teamName/assets // GET /teams/:teamName/assets
// Proxy team assets with required disposition filter. router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -146,20 +104,15 @@ function createCardApiRouter(db, requireAuth) {
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// Audit log for asset search (fire-and-forget)
let resultCount = 0; let resultCount = 0;
if (body && typeof body === 'object' && typeof body.total === 'number') { if (body && typeof body === 'object' && typeof body.total === 'number') {
resultCount = body.total; resultCount = body.total;
} else if (body && Array.isArray(body.assets)) { } else if (body && Array.isArray(body.assets)) {
resultCount = body.assets.length; resultCount = body.assets.length;
} }
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'card_search', action: 'card_search',
@@ -173,22 +126,15 @@ function createCardApiRouter(db, requireAuth) {
} }
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// GET /owner/:assetId // GET /owner/:assetId
// Proxy owner record lookup. router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -197,34 +143,21 @@ function createCardApiRouter(db, requireAuth) {
try { try {
const result = await getOwner(assetId); const result = await getOwner(assetId);
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
return res.json(body); return res.json(body);
} }
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/confirm // POST /queue/:queueItemId/confirm
// Confirm asset to a team via CARD API. router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); 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 { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body; const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' }); return res.status(400).json({ error: 'teamName is required.' });
} }
@@ -241,11 +173,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); 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.' }); 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); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -279,82 +201,39 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); 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 });
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,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); 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 || ''); const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) { if (confirmResult.ok) {
// Update queue item to complete await pool.query(
await dbRun(db, "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
// Audit log (fire-and-forget) 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 });
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,
});
return res.json({ success: true, cardResponse }); return res.json({ success: true, cardResponse });
} }
// Mutation failed — leave queue item as pending
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`; const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody); return res.status(confirmResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Confirm error:', err.message); 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 });
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,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/decline // POST /queue/:queueItemId/decline
// Decline asset from a team via CARD API. router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); 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 { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body; const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' }); return res.status(400).json({ error: 'teamName is required.' });
} }
@@ -371,11 +249,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); 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.' }); 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); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -409,80 +277,39 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); 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 });
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,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); 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 || ''); const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) { if (declineResult.ok) {
await dbRun(db, await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit(db, { 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 });
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 }); return res.json({ success: true, cardResponse });
} }
// Mutation failed
const errMsg = `Decline failed: HTTP ${declineResult.status}`; const errMsg = `Decline failed: HTTP ${declineResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody); return res.status(declineResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Decline error:', err.message); 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 });
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,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/redirect // POST /queue/:queueItemId/redirect
// Redirect asset from one team to another via CARD API. router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); 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 { queueItemId } = req.params;
const { fromTeam, toTeam, assetId } = req.body; const { fromTeam, toTeam, assetId } = req.body;
// Validate required fields
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) { if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' }); return res.status(400).json({ error: 'fromTeam is required.' });
} }
@@ -502,11 +328,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); 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.' }); 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); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -540,71 +356,33 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); 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 });
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,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); 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); const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) { if (redirectResult.ok) {
await dbRun(db, await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit(db, { 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 });
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 }); return res.json({ success: true, cardResponse });
} }
// Mutation failed
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`; const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
console.error('[card-api]', errMsg); 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 });
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,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody); return res.status(redirectResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Redirect error:', err.message); 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 });
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,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -4,26 +4,16 @@
const express = require('express'); const express = require('express');
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
const { requireAuth } = require('../middleware/auth');
function createFeedbackRouter(db, requireAuth) { function createFeedbackRouter() {
const router = express.Router(); const router = express.Router();
const GITLAB_URL = process.env.GITLAB_URL || ''; const GITLAB_URL = process.env.GITLAB_URL || '';
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || ''; const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
const GITLAB_PAT = process.env.GITLAB_PAT || ''; const GITLAB_PAT = process.env.GITLAB_PAT || '';
/** router.post('/', requireAuth(), async (req, res) => {
* 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) => {
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) { if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
return res.status(503).json({ error: 'Feedback integration not configured' }); return res.status(503).json({ error: 'Feedback integration not configured' });
} }

View File

@@ -1,19 +1,12 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings // Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth } = require('../middleware/auth');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
/** /**
* Find the most severe active finding related to an archived finding. * 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) { function findRelatedActive(archive, activeFindings) {
const archiveTitle = (archive.finding_title || '').toLowerCase(); const archiveTitle = (archive.finding_title || '').toLowerCase();
@@ -34,21 +27,13 @@ function findRelatedActive(archive, activeFindings) {
return { id: best.id, title: best.title, severity: best.severity }; return { id: best.id, title: best.title, severity: best.severity };
} }
function createIvantiArchiveRouter(db, requireAuth) { function createIvantiArchiveRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
/** // GET / — List archive records with optional state filtering
* GET /
* List archive records with optional state filtering.
*
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
* @returns {Object} 400 - { error: string } when state param is invalid
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { state } = req.query; const { state } = req.query;
@@ -61,43 +46,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
try { try {
let query = 'SELECT * FROM ivanti_finding_archives'; let query = 'SELECT * FROM ivanti_finding_archives';
const params = []; const params = [];
let paramIndex = 1;
if (state) { if (state) {
query += ' WHERE current_state = ?'; query += ` WHERE current_state = $${paramIndex++}`;
params.push(state); params.push(state);
} }
query += ' ORDER BY last_transition_at DESC'; query += ' ORDER BY last_transition_at DESC';
const archives = await new Promise((resolve, reject) => { const { rows: archives } = await pool.query(query, params);
db.all(query, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
// 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 = []; let activeFindings = [];
try { try {
const cacheRow = await new Promise((resolve, reject) => { const { rows: findingsRows } = await pool.query(
db.get( `SELECT id, title, host_name AS "hostName", severity FROM ivanti_findings WHERE state = 'open'`
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1', );
(err, row) => { activeFindings = findingsRows;
if (err) reject(err);
else resolve(row);
}
);
});
if (cacheRow && cacheRow.findings_json) {
activeFindings = JSON.parse(cacheRow.findings_json);
}
} catch (cacheErr) { } catch (cacheErr) {
console.warn('Failed to load findings cache for related-active matching:', cacheErr); console.warn('Failed to load findings for related-active matching:', cacheErr);
}
if (!Array.isArray(activeFindings)) {
activeFindings = [];
} }
// Enrich each archive record with related active finding info // Enrich each archive record with related active finding info
@@ -113,52 +82,28 @@ function createIvantiArchiveRouter(db, requireAuth) {
} }
}); });
/** // GET /stats — Summary counts by lifecycle state
* 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
*/
router.get('/stats', async (req, res) => { router.get('/stats', async (req, res) => {
try { try {
// Count archive records by state const { rows } = await pool.query(
const rows = await new Promise((resolve, reject) => { `SELECT current_state, COUNT(*) as count
db.all( FROM ivanti_finding_archives
`SELECT current_state, COUNT(*) as count GROUP BY current_state`
FROM ivanti_finding_archives );
GROUP BY current_state`,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
for (const row of rows) { for (const row of rows) {
if (stats.hasOwnProperty(row.current_state)) { 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 // ACTIVE = total live findings count
const cacheRow = await new Promise((resolve, reject) => { const countResult = await pool.query(
db.get( `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`
'SELECT total FROM ivanti_findings_cache WHERE id = 1', );
(err, row) => { stats.ACTIVE = parseInt(countResult.rows[0].total) || 0;
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;
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED; 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
* 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<TransitionRecord> }
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/:findingId/history', async (req, res) => { router.get('/:findingId/history', async (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
try { try {
const archive = await new Promise((resolve, reject) => { const { rows: archiveRows } = await pool.query(
db.get( 'SELECT id FROM ivanti_finding_archives WHERE finding_id = $1',
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?', [findingId]
[findingId], );
(err, row) => { const archive = archiveRows[0];
if (err) reject(err);
else resolve(row);
}
);
});
if (!archive) { if (!archive) {
return res.json({ finding_id: findingId, transitions: [] }); return res.json({ finding_id: findingId, transitions: [] });
} }
const transitions = await new Promise((resolve, reject) => { const { rows: transitions } = await pool.query(
db.all( `SELECT * FROM ivanti_archive_transitions
`SELECT * FROM ivanti_archive_transitions WHERE archive_id = $1
WHERE archive_id = ? ORDER BY transitioned_at DESC`,
ORDER BY transitioned_at DESC`, [archive.id]
[archive.id], );
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
res.json({ finding_id: findingId, transitions }); res.json({ finding_id: findingId, transitions });
} catch (err) { } catch (err) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); 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 logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
@@ -12,71 +13,34 @@ function isValidVendor(vendor) {
return trimmed.length > 0 && trimmed.length <= 200; return trimmed.length > 0 && trimmed.length <= 200;
} }
function createIvantiTodoQueueRouter(db, requireAuth) { function createIvantiTodoQueueRouter() {
const router = express.Router(); const router = express.Router();
/** // GET /api/ivanti/todo-queue
* GET /api/ivanti/todo-queue router.get('/', requireAuth(), async (req, res) => {
* try {
* Fetch the current user's queue items, ordered by vendor then created_at. const { rows } = await pool.query(
* `SELECT q.*
* @returns {Array<Object>} 200 - Array of queue items, each with: FROM ivanti_todo_queue q
* id, user_id, finding_id, finding_title, cves_json, ip_address, WHERE q.user_id = $1
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array) ORDER BY q.vendor ASC, q.created_at ASC`,
* @returns {Object} 500 - { error: string } on database error [req.user.id]
*/ );
router.get('/', requireAuth(db), (req, res) => { const parsed = rows.map((r) => ({
db.all( ...r,
`SELECT q.*, cves: r.cves_json ? JSON.parse(r.cves_json) : [],
o.value AS override_hostname }));
FROM ivanti_todo_queue q res.json(parsed);
LEFT JOIN ivanti_finding_overrides o } catch (err) {
ON o.finding_id = q.finding_id AND o.field = 'hostName' console.error('Error fetching todo queue:', err);
WHERE q.user_id = ? res.status(500).json({ error: 'Internal server error.' });
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);
}
);
}); });
/** // POST /api/ivanti/todo-queue/batch
* POST /api/ivanti/todo-queue/batch router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Add multiple findings to the current user's queue in a single transaction.
*
* @body {Object[]} findings - Required array of 1200 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<Object> } 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) => {
const { findings, workflow_type, vendor } = req.body; const { findings, workflow_type, vendor } = req.body;
// --- Validation ---
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
return res.status(400).json({ error: 'findings array must contain 1-200 items.' }); 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 vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
const userId = req.user.id; const userId = req.user.id;
// --- Transactional batch insert --- const client = await pool.connect();
// Prepare all row values upfront try {
const rows = findings.map((f) => { await client.query('BEGIN');
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 insertedIds = []; const insertedIds = [];
let insertError = null; for (const f of findings) {
let remaining = rows.length; 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(() => { const { rows } = await client.query(
db.run('BEGIN TRANSACTION');
rows.forEach((params) => {
db.run(
`INSERT INTO ivanti_todo_queue `INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
params, RETURNING id`,
function (err) { [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
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 });
}
);
});
}
}
}
); );
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
* POST /api/ivanti/todo-queue router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* 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) => {
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body; 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) { 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)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); 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)) { if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); 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 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 hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
const title = finding_title && typeof finding_title === 'string' const title = finding_title && typeof finding_title === 'string'
? finding_title.slice(0, 500) ? finding_title.slice(0, 500) : null;
: null;
db.run( try {
`INSERT INTO ivanti_todo_queue const { rows } = await pool.query(
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) `INSERT INTO ivanti_todo_queue
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type], VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
function (err) { RETURNING *`,
if (err) { [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
console.error('Error adding to queue:', err); );
return res.status(500).json({ error: 'Internal server error.' });
} const result = {
db.get( ...rows[0],
`SELECT q.*, o.value AS override_hostname cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
FROM ivanti_todo_queue q };
LEFT JOIN ivanti_finding_overrides o res.status(201).json(result);
ON o.finding_id = q.finding_id AND o.field = 'hostName' } catch (err) {
WHERE q.id = ?`, console.error('Error adding to queue:', err);
[this.lastID], res.status(500).json({ error: 'Internal server error.' });
(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);
}
);
}
);
}); });
/** // PUT /api/ivanti/todo-queue/:id
* PUT /api/ivanti/todo-queue/:id router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* 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) => {
const { id } = req.params; const { id } = req.params;
const { vendor, workflow_type, status } = req.body; 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.' }); return res.status(400).json({ error: 'status must be pending or complete.' });
} }
db.get( try {
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', const { rows: existingRows } = await pool.query(
[id, req.user.id], 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
(err, existing) => { [id, req.user.id]
if (err) { );
console.error(err); if (!existingRows[0]) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
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);
}
);
}
);
} }
);
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
* POST /api/ivanti/todo-queue/:id/redirect router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* 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) => {
const { id } = req.params; const { id } = req.params;
const { workflow_type, vendor } = req.body; const { workflow_type, vendor } = req.body;
// --- Validation ---
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
} }
if (!['CARD', 'GRANITE'].includes(workflow_type)) { if (!['CARD', 'GRANITE'].includes(workflow_type)) {
if (!isValidVendor(vendor)) { if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); 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) { if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' }); return res.status(400).json({ error: 'vendor must be under 200 chars.' });
} }
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
// --- Fetch original item scoped to current user --- try {
db.get( const { rows: origRows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
[id, req.user.id], [id, req.user.id]
(err, original) => { );
if (err) { const original = origRows[0];
console.error('Error fetching queue item for redirect:', err); if (!original) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
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);
}
);
}
);
} }
); 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
* DELETE /api/ivanti/todo-queue/completed router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* try {
* Bulk-delete all completed items for the current user. const result = await pool.query(
* IMPORTANT: This route must be registered BEFORE DELETE /:id. "DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
* [req.user.id]
* @returns {Object} 200 - { message: string, deleted: number } );
* @returns {Object} 500 - { error: string } on database error res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
*/ } catch (err) {
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { console.error('Error clearing completed queue items:', err);
db.run( res.status(500).json({ error: 'Internal server error.' });
"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/:id
* DELETE /api/ivanti/todo-queue/:id router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* 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) => {
const { id } = req.params; const { id } = req.params;
db.get( try {
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', const { rows } = await pool.query(
[id, req.user.id], 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
(err, row) => { [id, req.user.id]
if (err) { );
console.error(err); if (!rows[0]) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
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.' });
}
);
} }
);
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; return router;

View File

@@ -1,46 +1,17 @@
// Ivanti / RiskSense Workflow Routes // Ivanti / RiskSense Workflow Routes
// Data is cached in SQLite and refreshed on a daily schedule or on-demand. // Data is cached in PostgreSQL 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
const express = require('express'); 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 { ivantiPost } = require('../helpers/ivantiApi');
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours 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) { async function syncWorkflows() {
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) {
const apiKey = process.env.IVANTI_API_KEY; const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const firstName = process.env.IVANTI_FIRST_NAME || ''; const firstName = process.env.IVANTI_FIRST_NAME || '';
@@ -50,12 +21,10 @@ async function syncWorkflows(db) {
if (!apiKey) { if (!apiKey) {
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync'; const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
console.warn('[Ivanti]', errMsg); console.warn('[Ivanti]', errMsg);
await new Promise((resolve) => { await pool.query(
db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]
[errMsg], resolve );
);
});
return; return;
} }
@@ -107,7 +76,6 @@ async function syncWorkflows(db) {
const data = JSON.parse(result.body); const data = JSON.parse(result.body);
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
let total = 0; let total = 0;
let workflows = []; let workflows = [];
@@ -127,95 +95,89 @@ async function syncWorkflows(db) {
total = data.length; total = data.length;
} }
await new Promise((resolve, reject) => { await pool.query(
db.run( `UPDATE ivanti_sync_state
`UPDATE ivanti_sync_state SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
WHERE id=1`, [total, JSON.stringify(workflows)]
[total, JSON.stringify(workflows)], );
(err) => { if (err) reject(err); else resolve(); }
);
});
console.log(`[Ivanti] Sync complete — ${total} workflows`); console.log(`[Ivanti] Sync complete — ${total} workflows`);
} catch (err) { } catch (err) {
const msg = err.message || 'Unknown error'; const msg = err.message || 'Unknown error';
console.error('[Ivanti] Sync failed:', msg); console.error('[Ivanti] Sync failed:', msg);
await new Promise((resolve) => { await pool.query(
db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]
[msg], resolve );
);
});
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scheduler — runs sync immediately if >24h stale, then every 24h // Scheduler — runs sync immediately if >24h stale, then every 24h
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function scheduleSync(db) { async function scheduleSync() {
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => { try {
if (err || !row || !row.synced_at) { const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1');
syncWorkflows(db); const row = rows[0];
if (!row || !row.synced_at) {
syncWorkflows();
} else { } 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); const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
if (hoursSince >= 24) { if (hoursSince >= 24) {
syncWorkflows(db); syncWorkflows();
} else { } else {
const hoursUntil = (24 - hoursSince).toFixed(1); const hoursUntil = (24 - hoursSince).toFixed(1);
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`); 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 // Helper — read current state from DB and return as JSON-ready object
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function readState(db) { async function readState() {
return new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1', );
(err, row) => { const row = rows[0];
if (err) return reject(err); if (!row) return { total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null };
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
let workflows = []; let workflows = [];
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
resolve({ return {
total: row.total || 0, total: row.total || 0,
workflows, workflows,
synced_at: row.synced_at, synced_at: row.synced_at,
sync_status: row.sync_status, sync_status: row.sync_status,
error_message: row.error_message error_message: row.error_message
}); };
}
);
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router // Router
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createIvantiWorkflowsRouter(db, requireAuth) { function createIvantiWorkflowsRouter() {
const router = express.Router(); const router = express.Router();
// Init table and kick off scheduler (fire-and-forget on startup) // Kick off scheduler (fire-and-forget on startup)
initTable(db) scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err));
.then(() => scheduleSync(db))
.catch((err) => console.error('[Ivanti] Init failed:', err));
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
// GET / — return cached data (fast, no external call) // GET / — return cached data (fast, no external call)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
res.json(await readState(db)); res.json(await readState());
} catch { } catch {
res.status(500).json({ error: 'Database error reading sync state' }); 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 // POST /sync — trigger an immediate sync, await completion, return fresh state
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db); await syncWorkflows();
try { try {
res.json(await readState(db)); res.json(await readState());
} catch { } catch {
res.status(500).json({ error: 'Sync ran but could not read updated state' }); res.status(500).json({ error: 'Sync ran but could not read updated state' });
} }

View File

@@ -11,6 +11,7 @@
// - Rate limits enforced client-side (1440/day, 60/min burst) // - Rate limits enforced client-side (1440/day, 60/min burst)
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const jiraApi = require('../helpers/jiraApi'); const jiraApi = require('../helpers/jiraApi');
@@ -27,24 +28,14 @@ function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
} }
function createJiraTicketsRouter(db) { function createJiraTicketsRouter() {
const router = express.Router(); const router = express.Router();
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Jira API integration endpoints // Jira API integration endpoints
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
* 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) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' }); 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 { try {
const result = await jiraApi.testConnection(); const result = await jiraApi.testConnection();
if (result.ok) { if (result.ok) {
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_connection_test', action: 'jira_connection_test',
@@ -69,32 +60,11 @@ function createJiraTicketsRouter(db) {
} }
}); });
/** router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
* 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) => {
res.json(jiraApi.getRateLimitStatus()); res.json(jiraApi.getRateLimitStatus());
}); });
/** router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
* 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) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
@@ -132,27 +102,7 @@ function createJiraTicketsRouter(db) {
} }
}); });
/** router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); 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}`) ? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
: null; : null;
db.run( try {
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`, `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id], VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
function(err) { RETURNING id`,
if (err) { [cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
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
});
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_create_via_api', action: 'jira_ticket_create_via_api',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: this.lastID.toString(), entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey }, details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
ticket_key: ticketKey, ticket_key: ticketKey,
jira_url: jiraUrl, jira_url: jiraUrl,
message: 'Jira issue created and linked successfully' 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) { } catch (err) {
return res.status(502).json({ error: err.message }); return res.status(502).json({ error: err.message });
} }
}); });
/** router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
* 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) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
db.all( try {
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''", const { rows: tickets } = await pool.query(
[], "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
async (err, tickets) => { );
if (err) {
console.error(err); if (tickets.length === 0) {
return res.status(500).json({ error: 'Internal server error.' }); 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) { const keys = batch.map(t => t.ticket_key);
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); try {
} const result = await jiraApi.searchIssuesByKeys(keys);
if (!result.ok) {
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }; if (result.rateLimited) {
results.skipped += batch.length;
// Batch keys into groups of 100 for JQL (avoid overly long queries) results.errors.push('Jira rate limit hit during sync.');
const BATCH_SIZE = 100; break;
const batches = []; }
for (let i = 0; i < tickets.length; i += BATCH_SIZE) { results.failed += batch.length;
batches.push(tickets.slice(i, i + BATCH_SIZE)); results.errors.push(`Batch search failed: HTTP ${result.status}`);
} continue;
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); const issueMap = {};
try { for (const issue of (result.data.issues || [])) {
// Bulk JQL search — Charter-compliant, single request per batch issueMap[issue.key] = issue;
const result = await jiraApi.searchIssuesByKeys(keys); }
if (!result.ok) {
if (result.rateLimited) { for (const ticket of batch) {
results.skipped += batch.length; const issue = issueMap[ticket.ticket_key];
results.errors.push('Jira rate limit hit during sync.'); if (!issue) {
break; results.unchanged++;
}
results.failed += batch.length;
results.errors.push(`Batch search failed: HTTP ${result.status}`);
continue; continue;
} }
// Build a map of key → Jira issue data const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
const issueMap = {}; const jiraSummary = issue.fields.summary || ticket.summary;
for (const issue of (result.data.issues || [])) { const localStatus = mapJiraStatusToLocal(jiraStatus);
issueMap[issue.key] = issue;
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.' });
}
}); });
/** router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); 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.' }); return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
} }
try { const result = await jiraApi.getIssue(ticket.ticket_key);
const result = await jiraApi.getIssue(ticket.ticket_key); if (!result.ok) {
if (!result.ok) { if (result.rateLimited) {
if (result.rateLimited) { return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
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 });
} }
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);
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 });
} }
});
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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** router.get('/', requireAuth(), async (req, res) => {
* 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) => {
const { cve_id, vendor, status } = req.query; const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM jira_tickets WHERE 1=1'; let query = 'SELECT * FROM jira_tickets WHERE 1=1';
const params = []; const params = [];
let paramIndex = 1;
if (cve_id) { if (cve_id) {
query += ' AND cve_id = ?'; query += ` AND cve_id = $${paramIndex++}`;
params.push(cve_id); params.push(cve_id);
} }
if (vendor) { if (vendor) {
query += ' AND vendor = ?'; query += ` AND vendor = $${paramIndex++}`;
params.push(vendor); params.push(vendor);
} }
if (status) { if (status) {
query += ' AND status = ?'; query += ` AND status = $${paramIndex++}`;
params.push(status); params.push(status);
} }
query += ' ORDER BY created_at DESC'; query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => { try {
if (err) { const { rows } = await pool.query(query, params);
console.error('Error fetching JIRA tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows); res.json(rows);
}); } catch (err) {
console.error('Error fetching JIRA tickets:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body; const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
if (!cve_id || !isValidCveId(cve_id)) { if (!cve_id || !isValidCveId(cve_id)) {
@@ -527,51 +402,35 @@ function createJiraTicketsRouter(db) {
const ticketStatus = status || 'Open'; const ticketStatus = status || 'Open';
db.run( try {
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], VALUES ($1, $2, $3, $4, $5, $6, $7)
function(err) { RETURNING id`,
if (err) { [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
console.error('Error creating JIRA ticket:', err); );
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_create', action: 'jira_ticket_create',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: this.lastID.toString(), entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key, status: ticketStatus }, details: { cve_id, vendor, ticket_key, status: ticketStatus },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
message: 'JIRA ticket created successfully' message: 'JIRA ticket created successfully'
}); });
} } catch (err) {
); console.error('Error creating JIRA ticket:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
const { ticket_key, url, summary, status } = req.body; const { ticket_key, url, summary, status } = req.body;
@@ -590,70 +449,56 @@ function createJiraTicketsRouter(db) {
const fields = []; const fields = [];
const values = []; const values = [];
let paramIndex = 1;
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); } if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
if (url !== undefined) { fields.push('url = ?'); values.push(url); } if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); } if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
if (status !== undefined) { fields.push('status = ?'); values.push(status); } if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); }
if (fields.length === 0) { if (fields.length === 0) {
return res.status(400).json({ error: 'No fields to update.' }); return res.status(400).json({ error: 'No fields to update.' });
} }
fields.push('updated_at = CURRENT_TIMESTAMP'); fields.push('updated_at = NOW()');
values.push(id); values.push(id);
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const existing = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) { const result = await pool.query(
if (updateErr) { `UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
console.error('Error updating JIRA ticket:', updateErr); values
return res.status(500).json({ error: 'Internal server error.' }); );
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_update', action: 'jira_ticket_update',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: id, entityId: id,
details: { before: existing, changes: req.body }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
});
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
}); });
});
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.' });
}
}); });
/** router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
@@ -670,54 +515,48 @@ function createJiraTicketsRouter(db) {
// Standard_User: compliance linkage check // Standard_User: compliance linkage check
const ticketKey = ticket.ticket_key; const ticketKey = ticket.ticket_key;
db.all( try {
`SELECT ci.id, ci.extra_json const { rows: compLinks } = await pool.query(
FROM compliance_items ci `SELECT ci.id, ci.extra_json
JOIN compliance_uploads cu ON ci.upload_id = cu.id FROM compliance_items ci
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, JOIN compliance_uploads cu ON ci.upload_id = cu.id
[`%${ticketKey}%`], WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
(compErr, compLinks) => { [`%${ticketKey}%`]
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 isLinked = (compLinks || []).some(cl => { const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || ''; const json = cl.extra_json || '';
return json.includes(ticketKey); 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' });
}); });
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; return router;
@@ -727,10 +566,6 @@ function createJiraTicketsRouter(db) {
// Helpers // 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) { function mapJiraStatusToLocal(jiraStatus) {
if (!jiraStatus) return 'Open'; if (!jiraStatus) return 'Open';
const lower = jiraStatus.toLowerCase(); const lower = jiraStatus.toLowerCase();

View File

@@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
function createKnowledgeBaseRouter(db, upload) { function createKnowledgeBaseRouter(upload) {
const router = express.Router(); const router = express.Router();
// Helper to sanitize filename // Helper to sanitize filename
@@ -39,20 +40,8 @@ function createKnowledgeBaseRouter(db, upload) {
return ALLOWED_EXTENSIONS.has(ext); return ALLOWED_EXTENSIONS.has(ext);
} }
/** // POST /api/knowledge-base/upload
* POST /api/knowledge-base/upload router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
* 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) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('[KB Upload] Multer error:', err); console.error('[KB Upload] Multer error:', err);
@@ -70,7 +59,6 @@ function createKnowledgeBaseRouter(db, upload) {
const uploadedFile = req.file; const uploadedFile = req.file;
const { title, description, category } = req.body; const { title, description, category } = req.body;
// Validate required fields
if (!title || !title.trim()) { if (!title || !title.trim()) {
console.error('[KB Upload] Error: Title is missing'); console.error('[KB Upload] Error: Title is missing');
if (uploadedFile) fs.unlinkSync(uploadedFile.path); if (uploadedFile) fs.unlinkSync(uploadedFile.path);
@@ -81,7 +69,6 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(400).json({ error: 'No file uploaded' }); return res.status(400).json({ error: 'No file uploaded' });
} }
// Validate file type
if (!isValidFileType(uploadedFile.originalname)) { if (!isValidFileType(uploadedFile.originalname)) {
fs.unlinkSync(uploadedFile.path); fs.unlinkSync(uploadedFile.path);
return res.status(400).json({ error: 'File type not allowed' }); return res.status(400).json({ error: 'File type not allowed' });
@@ -96,172 +83,121 @@ function createKnowledgeBaseRouter(db, upload) {
const filePath = path.join(kbDir, filename); const filePath = path.join(kbDir, filename);
try { try {
// Keep file in temp location until DB insert succeeds
// Check if slug already exists // Check if slug already exists
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => { const { rows: existingRows } = await pool.query(
if (err) { 'SELECT id FROM knowledge_base WHERE slug = $1', [slug]
fs.unlinkSync(uploadedFile.path); );
console.error('Error checking slug:', err);
return res.status(500).json({ error: 'Database error' }); 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 logAudit({
const finalSlug = row ? `${slug}-${timestamp}` : slug; 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 res.json({
const insertSql = ` success: true,
INSERT INTO knowledge_base ( id: rows[0].id,
title, slug, description, category, file_path, file_name, title: title.trim(),
file_type, file_size, created_by slug: finalSlug,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) category: category || 'General'
`;
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'
});
}
);
}); });
} catch (error) { } catch (error) {
// Clean up temp file on error
if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path); if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
console.error('Error uploading knowledge base document:', error); console.error('Error uploading knowledge base document:', error);
res.status(500).json({ error: error.message || 'Failed to upload document' }); res.status(500).json({ error: error.message || 'Failed to upload document' });
} }
}); });
/** // GET /api/knowledge-base
* GET /api/knowledge-base router.get('/', requireAuth(), async (req, res) => {
* List all knowledge base articles. try {
* const { rows } = await pool.query(`
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }] SELECT
* @response 500 - { error: string } 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,
router.get('/', requireAuth(db), (req, res) => { u.username as created_by_username
const sql = ` FROM knowledge_base kb
SELECT LEFT JOIN users u ON kb.created_by = u.id
kb.id, kb.title, kb.slug, kb.description, kb.category, ORDER BY kb.created_at DESC
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' });
}
res.json(rows); 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 /api/knowledge-base/:id router.get('/:id', requireAuth(), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
const sql = ` try {
SELECT const { rows } = await pool.query(`
kb.id, kb.title, kb.slug, kb.description, kb.category, SELECT
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, kb.id, kb.title, kb.slug, kb.description, kb.category,
u.username as created_by_username kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
FROM knowledge_base kb u.username as created_by_username
LEFT JOIN users u ON kb.created_by = u.id FROM knowledge_base kb
WHERE kb.id = ? LEFT JOIN users u ON kb.created_by = u.id
`; WHERE kb.id = $1
`, [id]);
db.get(sql, [id], (err, row) => { if (!rows[0]) {
if (err) {
console.error('Error fetching article:', err);
return res.status(500).json({ error: 'Failed to fetch article' });
}
if (!row) {
return res.status(404).json({ error: 'Article not found' }); 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 /api/knowledge-base/:id/content router.get('/:id/content', requireAuth(), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching document:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch document' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Document not found' }); 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' }); return res.status(404).json({ error: 'File not found on disk' });
} }
// Log audit entry logAudit({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'VIEW_KB_ARTICLE', action: 'VIEW_KB_ARTICLE',
@@ -282,10 +217,7 @@ function createKnowledgeBaseRouter(db, upload) {
ipAddress: req.ip ipAddress: req.ip
}); });
// Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream'; 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')) { if (row.file_name.endsWith('.md')) {
contentType = 'text/plain; charset=utf-8'; contentType = 'text/plain; charset=utf-8';
} else if (row.file_name.endsWith('.txt')) { } else if (row.file_name.endsWith('.txt')) {
@@ -294,36 +226,26 @@ function createKnowledgeBaseRouter(db, upload) {
const safeFileName = row.file_name.replace(/["\r\n\\]/g, ''); const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
// Use inline instead of attachment to allow browser to display
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`); res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
// Allow iframe embedding from frontend origin
res.removeHeader('X-Frame-Options'); res.removeHeader('X-Frame-Options');
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000'; 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.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
res.sendFile(row.file_path); 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
* GET /api/knowledge-base/:id/download router.get('/:id/download', requireAuth(), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching document:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch document' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Document not found' }); 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' }); return res.status(404).json({ error: 'File not found on disk' });
} }
// Log audit entry logAudit({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'DOWNLOAD_KB_ARTICLE', 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-Type', row.file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`); res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
res.sendFile(row.file_path); 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 /api/knowledge-base/:id router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* 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) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching article for deletion:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch article' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Article not found' }); 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' }); return res.status(403).json({ error: 'You can only delete resources you created' });
} }
// Delete database record await pool.query('DELETE FROM knowledge_base WHERE id = $1', [id]);
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' });
}
// Delete file // Delete file
if (fs.existsSync(row.file_path)) { if (fs.existsSync(row.file_path)) {
fs.unlinkSync(row.file_path); fs.unlinkSync(row.file_path);
} }
// Log audit entry logAudit({
logAudit(db, { userId: req.user.id,
userId: req.user.id, username: req.user.username,
username: req.user.username, action: 'DELETE_KB_ARTICLE',
action: 'DELETE_KB_ARTICLE', entityType: 'knowledge_base',
entityType: 'knowledge_base', entityId: String(id),
entityId: String(id), details: { title: row.title },
details: { title: row.title }, ipAddress: req.ip
ipAddress: req.ip
});
res.json({ success: true });
}); });
});
res.json({ success: true });
} catch (err) {
console.error('Error deleting article:', err);
res.status(500).json({ error: 'Failed to delete article' });
}
}); });
return router; return router;

View File

@@ -1,13 +1,14 @@
// NVD CVE Lookup Routes // NVD CVE Lookup Routes
const express = require('express'); const express = require('express');
const { requireAuth } = require('../middleware/auth');
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/; const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
function createNvdLookupRouter(db, requireAuth) { function createNvdLookupRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
// Lookup CVE details from NVD API 2.0 // Lookup CVE details from NVD API 2.0
router.get('/lookup/:cveId', async (req, res) => { router.get('/lookup/:cveId', async (req, res) => {

View File

@@ -1,27 +1,22 @@
// User Management Routes (Admin only) // User Management Routes (Admin only)
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const pool = require('../db');
const { validateTeams } = require('../helpers/teams'); const { validateTeams } = require('../helpers/teams');
function createUsersRouter(db, requireAuth, requireGroup, logAudit) { function createUsersRouter(requireAuth, requireGroup, logAudit) {
const router = express.Router(); const router = express.Router();
// All routes require Admin group // All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin')); router.use(requireAuth(), requireGroup('Admin'));
// Get all users // Get all users
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const users = await new Promise((resolve, reject) => { const { rows: users } = await pool.query(
db.all( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login FROM users ORDER BY created_at DESC`
FROM users ORDER BY created_at DESC`, );
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
// Parse bu_teams into teams array for each user // Parse bu_teams into teams array for each user
const usersWithTeams = users.map(u => ({ const usersWithTeams = users.map(u => ({
...u, ...u,
@@ -37,17 +32,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Get single user // Get single user
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login FROM users WHERE id = $1`,
FROM users WHERE id = ?`, [req.params.id]
[req.params.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user) { if (!user) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
@@ -90,19 +81,16 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try { try {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
const result = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.run( `INSERT INTO users (username, email, password_hash, user_group, bu_teams)
`INSERT INTO users (username, email, password_hash, user_group, bu_teams) VALUES ($1, $2, $3, $4, $5)
VALUES (?, ?, ?, ?, ?)`, RETURNING id`,
[username, email, passwordHash, userGroup, teamsStr], [username, email, passwordHash, userGroup, teamsStr]
function(err) { );
if (err) reject(err);
else resolve({ id: this.lastID });
}
);
});
logAudit(db, { const result = rows[0];
logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_create', action: 'user_create',
@@ -125,7 +113,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
}); });
} catch (err) { } catch (err) {
console.error('Create user error:', 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' }); return res.status(409).json({ error: 'Username or email already exists' });
} }
res.status(500).json({ error: 'Failed to create user' }); res.status(500).json({ error: 'Failed to create user' });
@@ -165,16 +153,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try { try {
// Fetch current user record before update (needed for group change audit) // Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => { const { rows: currentRows } = await pool.query(
db.get( 'SELECT user_group, bu_teams FROM users WHERE id = $1',
'SELECT user_group, bu_teams FROM users WHERE id = ?', [userId]
[userId], );
(err, row) => {
if (err) reject(err); const currentUser = currentRows[0];
else resolve(row);
}
);
});
if (!currentUser) { if (!currentUser) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
@@ -182,30 +166,31 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const updates = []; const updates = [];
const values = []; const values = [];
let paramIndex = 1;
if (username) { if (username) {
updates.push('username = ?'); updates.push(`username = $${paramIndex++}`);
values.push(username); values.push(username);
} }
if (email) { if (email) {
updates.push('email = ?'); updates.push(`email = $${paramIndex++}`);
values.push(email); values.push(email);
} }
if (password) { if (password) {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
updates.push('password_hash = ?'); updates.push(`password_hash = $${paramIndex++}`);
values.push(passwordHash); values.push(passwordHash);
} }
if (group) { if (group) {
updates.push('user_group = ?'); updates.push(`user_group = $${paramIndex++}`);
values.push(group); values.push(group);
} }
if (typeof is_active === 'boolean') { if (typeof is_active === 'boolean') {
updates.push('is_active = ?'); updates.push(`is_active = $${paramIndex++}`);
values.push(is_active ? 1 : 0); values.push(is_active);
} }
if (typeof bu_teams === 'string') { if (typeof bu_teams === 'string') {
updates.push('bu_teams = ?'); updates.push(`bu_teams = $${paramIndex++}`);
values.push(bu_teams); values.push(bu_teams);
} }
@@ -215,16 +200,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
values.push(userId); values.push(userId);
await new Promise((resolve, reject) => { await pool.query(
db.run( `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values
values, );
function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
}
);
});
const updatedFields = {}; const updatedFields = {};
if (username) updatedFields.username = username; if (username) updatedFields.username = username;
@@ -234,7 +213,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
if (password) updatedFields.password_changed = true; if (password) updatedFields.password_changed = true;
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams; if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_update', action: 'user_update',
@@ -246,7 +225,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Log specific audit entry for group changes // Log specific audit entry for group changes
if (group && group !== currentUser.user_group) { if (group && group !== currentUser.user_group) {
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_group_change', action: 'user_group_change',
@@ -262,7 +241,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Log specific audit entry for bu_teams changes // Log specific audit entry for bu_teams changes
if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) { if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) {
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_teams_change', action: 'user_teams_change',
@@ -278,15 +257,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// If user was deactivated, delete their sessions // If user was deactivated, delete their sessions
if (is_active === false) { if (is_active === false) {
await new Promise((resolve) => { await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
} }
res.json({ message: 'User updated successfully' }); res.json({ message: 'User updated successfully' });
} catch (err) { } catch (err) {
console.error('Update user error:', 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' }); return res.status(409).json({ error: 'Username or email already exists' });
} }
res.status(500).json({ error: 'Failed to update user' }); res.status(500).json({ error: 'Failed to update user' });
@@ -304,31 +281,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try { try {
// Look up the user before deleting // Look up the user before deleting
const targetUser = await new Promise((resolve, reject) => { const { rows: userRows } = await pool.query(
db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => { 'SELECT username FROM users WHERE id = $1',
if (err) reject(err); [userId]
else resolve(row); );
}); const targetUser = userRows[0];
});
// Delete sessions first (foreign key) // Delete sessions first (foreign key)
await new Promise((resolve) => { await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
// Delete user // Delete user
const result = await new Promise((resolve, reject) => { const result = await pool.query('DELETE FROM users WHERE id = $1', [userId]);
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_delete', action: 'user_delete',

File diff suppressed because it is too large Load Diff