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:
@@ -1,21 +1,19 @@
|
||||
// Audit Log Helper
|
||||
// Fire-and-forget insert - never blocks the response
|
||||
const pool = require('../db');
|
||||
|
||||
function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) {
|
||||
function logAudit({ userId, username, action, entityType, entityId, details, ipAddress }) {
|
||||
const detailsStr = details && typeof details === 'object'
|
||||
? JSON.stringify(details)
|
||||
: details || null;
|
||||
|
||||
db.run(
|
||||
pool.query(
|
||||
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Audit log error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null]
|
||||
).catch((err) => {
|
||||
console.error('Audit log error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = logAudit;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Authentication Middleware
|
||||
const pool = require('../db');
|
||||
|
||||
// Require authenticated user
|
||||
function requireAuth(db) {
|
||||
// Require authenticated user — no parameters needed, pool is imported directly
|
||||
function requireAuth() {
|
||||
return async (req, res, next) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
@@ -10,19 +11,15 @@ function requireAuth(db) {
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
[sessionId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
const session = rows[0];
|
||||
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Session expired or invalid' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// routes/archerTickets.js
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
@@ -13,42 +14,43 @@ function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createArcherTicketsRouter(db) {
|
||||
function createArcherTicketsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// Get all Archer tickets (with optional filters)
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
query += ` AND cve_id = $${paramIndex++}`;
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
query += ` AND vendor = $${paramIndex++}`;
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
query += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching Archer tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching Archer tickets:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create Archer ticket
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||
|
||||
// Validation
|
||||
@@ -73,38 +75,38 @@ function createArcherTicketsRouter(db) {
|
||||
|
||||
const validatedStatus = status || 'Draft';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating Archer ticket:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id]
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(this.lastID),
|
||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(rows[0].id),
|
||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'Archer ticket created successfully'
|
||||
});
|
||||
res.status(201).json({
|
||||
id: rows[0].id,
|
||||
message: 'Archer ticket created successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating Archer ticket:', err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update Archer ticket
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { exc_number, archer_url, status } = req.body;
|
||||
|
||||
@@ -124,29 +126,27 @@ function createArcherTicketsRouter(db) {
|
||||
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||
}
|
||||
|
||||
// Get existing ticket
|
||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (exc_number !== undefined) {
|
||||
updates.push('exc_number = ?');
|
||||
updates.push(`exc_number = $${paramIndex++}`);
|
||||
params.push(exc_number.trim());
|
||||
}
|
||||
if (archer_url !== undefined) {
|
||||
updates.push('archer_url = ?');
|
||||
updates.push(`archer_url = $${paramIndex++}`);
|
||||
params.push(archer_url || null);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
updates.push(`status = $${paramIndex++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
@@ -154,73 +154,47 @@ function createArcherTicketsRouter(db) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
updates.push('updated_at = NOW()');
|
||||
params.push(id);
|
||||
|
||||
db.run(
|
||||
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params,
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'UPDATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(id),
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
||||
}
|
||||
const result = await pool.query(
|
||||
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||
params
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: perform the actual Archer ticket deletion
|
||||
function performArcherDelete(db, req, res, id, ticket) {
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
action: 'UPDATE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(id),
|
||||
details: { deleted: ticket },
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
res.json({ message: 'Archer ticket updated successfully', changes: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Archer ticket
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||
const ticket = rows[0];
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
// Admin bypasses all delete restrictions
|
||||
if (req.user.group === 'Admin') {
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
return performArcherDelete();
|
||||
}
|
||||
|
||||
// Standard_User: ownership check
|
||||
@@ -230,53 +204,63 @@ function createArcherTicketsRouter(db) {
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const excNumber = ticket.exc_number;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${excNumber}%`],
|
||||
(compErr, compLinks) => {
|
||||
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows: compLinks } = await pool.query(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
|
||||
[`%${excNumber}%`]
|
||||
);
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(excNumber);
|
||||
});
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(excNumber);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performArcherDelete(db, req, res, id, ticket);
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (compErr) {
|
||||
if (!compErr.message.includes('does not exist')) throw compErr;
|
||||
}
|
||||
|
||||
return performArcherDelete();
|
||||
|
||||
async function performArcherDelete() {
|
||||
await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
entityType: 'archer_ticket',
|
||||
entityId: String(id),
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /status-trend — ticket counts grouped by creation date + status
|
||||
// Used for time-based Archer pipeline chart on the Compliance page.
|
||||
router.get('/status-trend', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||
FROM archer_tickets
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching Archer status trend:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ statusTrend: rows });
|
||||
}
|
||||
);
|
||||
router.get('/status-trend', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||
FROM archer_tickets
|
||||
GROUP BY DATE(created_at), status
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
res.json({ statusTrend: rows });
|
||||
} catch (err) {
|
||||
console.error('Error fetching Archer status trend:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Atlas InfoSec Action Plans Routes
|
||||
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache
|
||||
// Proxies CRUD operations to the Atlas API and maintains a local cache
|
||||
// for fast badge rendering on the ReportingPage.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||
|
||||
@@ -13,34 +14,13 @@ const path = require('path');
|
||||
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// Diagnostic log helper — writes to atlas-sync-debug.log in the backend folder
|
||||
// Diagnostic log helper
|
||||
function syncLog(msg) {
|
||||
const line = `${new Date().toISOString()} ${msg}\n`;
|
||||
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbAll(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure aggregation function — exported for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,7 +35,7 @@ function aggregateAtlasMetrics(rows) {
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.has_action_plan === 1) {
|
||||
if (row.has_action_plan === true || row.has_action_plan === 1) {
|
||||
result.hostsWithPlans++;
|
||||
} else {
|
||||
result.hostsWithoutPlans++;
|
||||
@@ -65,7 +45,6 @@ function aggregateAtlasMetrics(rows) {
|
||||
try {
|
||||
plans = JSON.parse(row.plans_json);
|
||||
} catch (e) {
|
||||
// Invalid JSON — skip plan details for this row
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -73,11 +52,9 @@ function aggregateAtlasMetrics(rows) {
|
||||
|
||||
for (const plan of plans) {
|
||||
result.totalPlans++;
|
||||
|
||||
if (plan.plan_type) {
|
||||
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
|
||||
}
|
||||
|
||||
if (plan.status) {
|
||||
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
|
||||
}
|
||||
@@ -90,28 +67,17 @@ function aggregateAtlasMetrics(rows) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createAtlasRouter(db, requireAuth) {
|
||||
function createAtlasRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metrics
|
||||
// Return aggregated Atlas metrics for chart rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number,
|
||||
// plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number },
|
||||
// totalPlans: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/metrics', requireAuth(db), async (req, res) => {
|
||||
router.get('/metrics', requireAuth(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
const { rows } = await pool.query(
|
||||
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||
);
|
||||
const metrics = aggregateAtlasMetrics(rows);
|
||||
@@ -122,23 +88,14 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Return all cached Atlas rows for badge rendering.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Response 200:
|
||||
// [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ]
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — DB query failure
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), async (req, res) => {
|
||||
router.get('/status', requireAuth(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
const { rows } = await pool.query(
|
||||
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||
);
|
||||
res.json(rows);
|
||||
@@ -148,49 +105,23 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /sync
|
||||
// Sync Atlas action plan data for all hosts found in the Ivanti cache.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body: none
|
||||
// Response 200:
|
||||
// { synced: number, withPlans: number, failed: number }
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 500: { error: string } — sync failure or Ivanti cache parse error
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read Ivanti findings cache and extract unique non-null hostIds
|
||||
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`);
|
||||
if (!cacheRow || !cacheRow.findings_json) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
let findings;
|
||||
try {
|
||||
findings = JSON.parse(cacheRow.findings_json);
|
||||
} catch (parseErr) {
|
||||
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
|
||||
}
|
||||
|
||||
const hostIdSet = new Set();
|
||||
for (const f of findings) {
|
||||
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
|
||||
hostIdSet.add(f.hostId);
|
||||
}
|
||||
}
|
||||
const hostIds = [...hostIdSet];
|
||||
// Read Ivanti findings and extract unique non-null hostIds
|
||||
const { rows: findingsRows } = await pool.query(
|
||||
`SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
|
||||
);
|
||||
const hostIds = findingsRows.map(r => r.host_id);
|
||||
|
||||
if (hostIds.length === 0) {
|
||||
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// 2. Process hosts in batches of 5 concurrent requests
|
||||
let synced = 0;
|
||||
let withPlans = 0;
|
||||
let failed = 0;
|
||||
@@ -219,7 +150,6 @@ function createAtlasRouter(db, requireAuth) {
|
||||
let activePlans = [];
|
||||
try {
|
||||
const parsed = JSON.parse(result.body);
|
||||
// Atlas returns { active: [...], inactive: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||
@@ -233,30 +163,24 @@ function createAtlasRouter(db, requireAuth) {
|
||||
activePlans = [];
|
||||
}
|
||||
|
||||
// Badge counts only active plans — inactive are historical
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0 ? 1 : 0;
|
||||
|
||||
console.log(`[Atlas Sync] Host ${hostId}: status=${result.status}, activePlans=${activePlans.length}, allPlans=${allPlans.length}, hasActionPlan=${hasActionPlan}`);
|
||||
const hasActionPlan = planCount > 0;
|
||||
|
||||
try {
|
||||
// If Atlas returns 0 plans but we have a recent optimistic
|
||||
// entry (from bulk creation within the last 10 minutes),
|
||||
// keep the optimistic value — Atlas's GET may lag behind.
|
||||
if (hasActionPlan === 0) {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
if (!hasActionPlan) {
|
||||
const { rows: existingRows } = await pool.query(
|
||||
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`,
|
||||
[hostId]
|
||||
);
|
||||
if (existing && existing.has_action_plan === 1) {
|
||||
const existing = existingRows[0];
|
||||
if (existing && existing.has_action_plan === true) {
|
||||
let existingPlans = [];
|
||||
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
|
||||
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
|
||||
if (hasBulkStub) {
|
||||
const ageMs = Date.now() - new Date(existing.synced_at + 'Z').getTime();
|
||||
const ageMs = Date.now() - new Date(existing.synced_at).getTime();
|
||||
const TEN_MINUTES = 10 * 60 * 1000;
|
||||
if (ageMs < TEN_MINUTES) {
|
||||
console.log(`[Atlas Sync] Host ${hostId}: keeping optimistic bulk-create entry (${Math.round(ageMs / 1000)}s old)`);
|
||||
synced++;
|
||||
withPlans++;
|
||||
continue;
|
||||
@@ -265,14 +189,14 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
}
|
||||
|
||||
await dbRun(db,
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = excluded.has_action_plan,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
has_action_plan = EXCLUDED.has_action_plan,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||
);
|
||||
} catch (dbErr) {
|
||||
@@ -283,13 +207,12 @@ function createAtlasRouter(db, requireAuth) {
|
||||
if (hasActionPlan) withPlans++;
|
||||
} else {
|
||||
failed++;
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}, body=${result.body}`);
|
||||
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Log audit entry
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_SYNC',
|
||||
@@ -306,18 +229,8 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /hosts/:hostId/action-plans
|
||||
// Proxy to Atlas API — returns live action plan data for a single host.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Response 2xx: proxied Atlas response body (parsed JSON or raw)
|
||||
// Response 400: { error: string } — invalid hostId
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
|
||||
router.get('/hosts/:hostId/action-plans', requireAuth(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
@@ -329,23 +242,13 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
try {
|
||||
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
// Forward non-2xx Atlas responses to the client
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -354,22 +257,8 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PUT /hosts/:hostId/action-plans
|
||||
// Create a new action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD),
|
||||
// qualys_id?: string, active_host_findings_id?: string,
|
||||
// jira_vnr?: string, archer_exc?: string }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.put('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
@@ -380,11 +269,9 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
const { plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
@@ -392,7 +279,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
try {
|
||||
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_CREATE_PLAN',
|
||||
@@ -404,19 +291,11 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -425,20 +304,8 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PATCH /hosts/:hostId/action-plans
|
||||
// Update an existing action plan for a host.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Params: hostId (positive integer)
|
||||
// Request body:
|
||||
// { action_plan_id: string (non-empty), updates: object (non-null, non-array) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid hostId, action_plan_id, or updates
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.patch('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
@@ -449,11 +316,9 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
const { action_plan_id, updates } = req.body || {};
|
||||
|
||||
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
|
||||
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
return res.status(400).json({ error: 'updates is required and must be an object' });
|
||||
}
|
||||
@@ -461,7 +326,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
try {
|
||||
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_UPDATE_PLAN',
|
||||
@@ -473,19 +338,11 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -494,41 +351,24 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /hosts/bulk-action-plans
|
||||
// Create action plans for multiple hosts at once.
|
||||
// Auth: Admin or Standard_User
|
||||
//
|
||||
// Request body:
|
||||
// { host_ids: number[] (non-empty, positive integers),
|
||||
// plan_type: string (one of VALID_PLAN_TYPES),
|
||||
// commit_date: string (YYYY-MM-DD) }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid host_ids, plan_type, or commit_date
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/hosts/bulk-action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const { host_ids, plan_type, commit_date } = req.body || {};
|
||||
|
||||
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
|
||||
for (const id of host_ids) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||
}
|
||||
|
||||
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||
}
|
||||
@@ -538,40 +378,34 @@ function createAtlasRouter(db, requireAuth) {
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
|
||||
// Optimistically update local cache for all submitted hosts.
|
||||
// Atlas's individual GET endpoint may lag behind the bulk
|
||||
// creation, so we mark every host as having a plan now rather
|
||||
// than waiting for the next sync to discover it.
|
||||
// Optimistically update local cache
|
||||
for (const hid of host_ids) {
|
||||
try {
|
||||
const existing = await dbGet(db,
|
||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = ?`,
|
||||
const { rows: existingRows } = await pool.query(
|
||||
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
|
||||
[hid]
|
||||
);
|
||||
const existing = existingRows[0];
|
||||
|
||||
let existingPlans = [];
|
||||
if (existing && existing.plans_json) {
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) { /* ignore */ }
|
||||
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
||||
}
|
||||
|
||||
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||
const updatedPlans = [...existingPlans, stubPlan];
|
||||
const newCount = updatedPlans.length;
|
||||
|
||||
await dbRun(db,
|
||||
await pool.query(
|
||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||
VALUES (?, 1, ?, ?, datetime('now'))
|
||||
VALUES ($1, true, $2, $3, NOW())
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
has_action_plan = 1,
|
||||
plan_count = excluded.plan_count,
|
||||
plans_json = excluded.plans_json,
|
||||
synced_at = excluded.synced_at`,
|
||||
has_action_plan = true,
|
||||
plan_count = EXCLUDED.plan_count,
|
||||
plans_json = EXCLUDED.plans_json,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
@@ -579,7 +413,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'ATLAS_BULK_CREATE_PLANS',
|
||||
@@ -592,11 +426,7 @@ function createAtlasRouter(db, requireAuth) {
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -605,29 +435,16 @@ function createAtlasRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST /hosts/vulnerabilities
|
||||
// Fetch active Ivanti vulnerabilities for multiple hosts from Atlas.
|
||||
// Used by the bulk action plan modal to populate the qualys_id dropdown.
|
||||
// Auth: any authenticated user
|
||||
//
|
||||
// Request body: { host_ids: number[] }
|
||||
// Response 2xx: proxied Atlas response body
|
||||
// Response 400: { error: string } — invalid host_ids
|
||||
// Response 503: { error: string } — Atlas not configured
|
||||
// Response 502: { error: string } — Atlas API unreachable
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => {
|
||||
router.post('/hosts/vulnerabilities', requireAuth(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||
}
|
||||
|
||||
const { host_ids } = req.body || {};
|
||||
|
||||
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
}
|
||||
|
||||
for (const id of host_ids) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||
@@ -637,24 +454,13 @@ function createAtlasRouter(db, requireAuth) {
|
||||
try {
|
||||
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
|
||||
|
||||
console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length);
|
||||
console.log('[Atlas] Response preview:', result.body?.substring(0, 500));
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||
res.status(result.status).json(body);
|
||||
} else {
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (e) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||
res.status(result.status).json(errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Audit Log Routes (Admin only)
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
|
||||
function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
function createAuditLogRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
router.use(requireAuth(), requireGroup('Admin'));
|
||||
|
||||
// Get paginated audit logs with filters
|
||||
router.get('/', async (req, res) => {
|
||||
@@ -24,25 +26,26 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
|
||||
let where = [];
|
||||
let params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (user) {
|
||||
where.push('username LIKE ?');
|
||||
where.push(`username ILIKE $${paramIndex++}`);
|
||||
params.push(`%${user}%`);
|
||||
}
|
||||
if (action) {
|
||||
where.push('action = ?');
|
||||
where.push(`action = $${paramIndex++}`);
|
||||
params.push(action);
|
||||
}
|
||||
if (entityType) {
|
||||
where.push('entity_type = ?');
|
||||
where.push(`entity_type = $${paramIndex++}`);
|
||||
params.push(entityType);
|
||||
}
|
||||
if (startDate) {
|
||||
where.push('created_at >= ?');
|
||||
where.push(`created_at >= $${paramIndex++}`);
|
||||
params.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.push('created_at <= ?');
|
||||
where.push(`created_at <= $${paramIndex++}`);
|
||||
params.push(endDate + ' 23:59:59');
|
||||
}
|
||||
|
||||
@@ -50,36 +53,25 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
|
||||
try {
|
||||
// Get total count
|
||||
const countRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
|
||||
params,
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// Get paginated results
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
const dataResult = await pool.query(
|
||||
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
res.json({
|
||||
logs: rows,
|
||||
logs: dataResult.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: pageSize,
|
||||
total: countRow.total,
|
||||
totalPages: Math.ceil(countRow.total / pageSize)
|
||||
total: total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -91,16 +83,9 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
// Get distinct action types for filter dropdown
|
||||
router.get('/actions', async (req, res) => {
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT DISTINCT action FROM audit_logs ORDER BY action',
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT action FROM audit_logs ORDER BY action'
|
||||
);
|
||||
res.json(rows.map(r => r.action));
|
||||
} catch (err) {
|
||||
console.error('Audit log actions error:', err);
|
||||
|
||||
@@ -3,6 +3,7 @@ const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
@@ -13,7 +14,7 @@ const loginLimiter = rateLimit({
|
||||
message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
|
||||
});
|
||||
|
||||
function createAuthRouter(db, logAudit) {
|
||||
function createAuthRouter(logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -39,19 +40,14 @@ function createAuthRouter(db, logAudit) {
|
||||
|
||||
try {
|
||||
// Find user
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT * FROM users WHERE username = ?',
|
||||
[username],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: null,
|
||||
username: username,
|
||||
action: 'login_failed',
|
||||
@@ -64,7 +60,7 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: user.id,
|
||||
username: username,
|
||||
action: 'login_failed',
|
||||
@@ -79,7 +75,7 @@ function createAuthRouter(db, logAudit) {
|
||||
// Verify password
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: user.id,
|
||||
username: username,
|
||||
action: 'login_failed',
|
||||
@@ -96,28 +92,16 @@ function createAuthRouter(db, logAudit) {
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
// Create session
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)',
|
||||
[sessionId, user.id, expiresAt.toISOString()],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)',
|
||||
[sessionId, user.id, expiresAt.toISOString()]
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[user.id],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Set cookie
|
||||
res.cookie('session_id', sessionId, {
|
||||
@@ -127,7 +111,7 @@ function createAuthRouter(db, logAudit) {
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
});
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: 'login',
|
||||
@@ -166,27 +150,31 @@ function createAuthRouter(db, logAudit) {
|
||||
|
||||
if (sessionId) {
|
||||
// Look up user before deleting session
|
||||
const session = await new Promise((resolve) => {
|
||||
db.get(
|
||||
let session = null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id as user_id, u.username FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ?`,
|
||||
[sessionId],
|
||||
(err, row) => resolve(row || null)
|
||||
WHERE s.session_id = $1`,
|
||||
[sessionId]
|
||||
);
|
||||
});
|
||||
session = rows[0] || null;
|
||||
} catch (err) {
|
||||
// Non-critical — proceed with logout
|
||||
}
|
||||
|
||||
// Delete session from database
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
'DELETE FROM sessions WHERE session_id = ?',
|
||||
[sessionId],
|
||||
() => resolve()
|
||||
try {
|
||||
await pool.query(
|
||||
'DELETE FROM sessions WHERE session_id = $1',
|
||||
[sessionId]
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
// Non-critical — proceed with logout
|
||||
}
|
||||
|
||||
if (session) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: session.user_id,
|
||||
username: session.username,
|
||||
action: 'logout',
|
||||
@@ -221,19 +209,15 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
[sessionId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
const session = rows[0];
|
||||
|
||||
if (!session) {
|
||||
res.clearCookie('session_id');
|
||||
@@ -271,18 +255,14 @@ function createAuthRouter(db, logAudit) {
|
||||
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
|
||||
* @returns {object} 500 - { error: 'Failed to fetch profile' }
|
||||
*/
|
||||
router.get('/profile', requireAuth(db), async (req, res) => {
|
||||
router.get('/profile', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?',
|
||||
[req.user.id],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
res.clearCookie('session_id');
|
||||
@@ -327,7 +307,7 @@ function createAuthRouter(db, logAudit) {
|
||||
* @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' }
|
||||
* @returns {object} 500 - { error: 'Failed to change password' }
|
||||
*/
|
||||
router.post('/change-password', requireAuth(db), passwordChangeLimiter, async (req, res) => {
|
||||
router.post('/change-password', requireAuth(), passwordChangeLimiter, async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
@@ -340,16 +320,12 @@ function createAuthRouter(db, logAudit) {
|
||||
|
||||
try {
|
||||
// Fetch user's password hash and active status
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT password_hash, is_active FROM users WHERE id = ?',
|
||||
[req.user.id],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
'SELECT password_hash, is_active FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: 'Account is disabled' });
|
||||
@@ -363,18 +339,12 @@ function createAuthRouter(db, logAudit) {
|
||||
|
||||
// Hash new password and update
|
||||
const newHash = await bcrypt.hash(newPassword, 10);
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[newHash, req.user.id],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $1 WHERE id = $2',
|
||||
[newHash, req.user.id]
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'password_change',
|
||||
@@ -401,17 +371,9 @@ function createAuthRouter(db, logAudit) {
|
||||
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
|
||||
* @returns {object} 500 - { error: 'Cleanup failed' }
|
||||
*/
|
||||
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
router.post('/cleanup-sessions', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
"DELETE FROM sessions WHERE expires_at < datetime('now')",
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
await pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
|
||||
res.json({ message: 'Expired sessions cleaned up' });
|
||||
} catch (err) {
|
||||
console.error('Session cleanup error:', err);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// the two-step update_token flow for mutations.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const {
|
||||
isConfigured,
|
||||
@@ -16,21 +17,6 @@ const {
|
||||
redirectAsset,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB helpers — promise wrappers for callback-based SQLite API
|
||||
// ---------------------------------------------------------------------------
|
||||
function dbRun(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
}
|
||||
|
||||
function dbGet(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — maps CARD API / token errors to client responses
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,7 +24,6 @@ function handleCardError(err, res) {
|
||||
const msg = err.message || String(err);
|
||||
console.error('[card-api]', msg);
|
||||
|
||||
// Token endpoint errors (from acquireToken rejections)
|
||||
if (msg.includes('Token acquisition failed')) {
|
||||
if (msg.includes('HTTP 401')) {
|
||||
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
|
||||
@@ -51,7 +36,6 @@ function handleCardError(err, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// API call errors (after automatic 401 retry in helper)
|
||||
if (msg.includes('401')) {
|
||||
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
|
||||
}
|
||||
@@ -59,73 +43,47 @@ function handleCardError(err, res) {
|
||||
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
|
||||
}
|
||||
|
||||
// Catch-all
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createCardApiRouter(db, requireAuth) {
|
||||
function createCardApiRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /status
|
||||
// Returns whether the CARD API integration is configured.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/status', requireAuth(db), (req, res) => {
|
||||
router.get('/status', requireAuth(), (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({
|
||||
configured: false,
|
||||
error: 'CARD API is not configured.',
|
||||
missingVars,
|
||||
});
|
||||
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
res.json({ configured: true });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams
|
||||
// Proxy CARD teams list.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTeams();
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
// CARD API wraps teams in { teams: [...], response_time: ... }
|
||||
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
||||
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
|
||||
return res.json(teams);
|
||||
}
|
||||
|
||||
// Forward CARD error status
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /teams/:teamName/assets
|
||||
// Proxy team assets with required disposition filter.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
@@ -146,20 +104,15 @@ function createCardApiRouter(db, requireAuth) {
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
||||
|
||||
// Audit log for asset search (fire-and-forget)
|
||||
let resultCount = 0;
|
||||
if (body && typeof body === 'object' && typeof body.total === 'number') {
|
||||
resultCount = body.total;
|
||||
} else if (body && Array.isArray(body.assets)) {
|
||||
resultCount = body.assets.length;
|
||||
}
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_search',
|
||||
@@ -173,22 +126,15 @@ function createCardApiRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// GET /owner/:assetId
|
||||
// Proxy owner record lookup.
|
||||
// -------------------------------------------------------------------
|
||||
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
@@ -197,34 +143,21 @@ function createCardApiRouter(db, requireAuth) {
|
||||
|
||||
try {
|
||||
const result = await getOwner(assetId);
|
||||
|
||||
if (result.ok) {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
body = result.body;
|
||||
}
|
||||
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
||||
return res.json(body);
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try {
|
||||
errorBody = JSON.parse(result.body);
|
||||
} catch (_) {
|
||||
errorBody = { error: result.body };
|
||||
}
|
||||
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
||||
return res.status(result.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/confirm
|
||||
// Confirm asset to a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
@@ -232,7 +165,6 @@ function createCardApiRouter(db, requireAuth) {
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
@@ -241,11 +173,11 @@ function createCardApiRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
const item = rows[0];
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
@@ -254,20 +186,10 @@ function createCardApiRouter(db, requireAuth) {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
@@ -279,82 +201,39 @@ function createCardApiRouter(db, requireAuth) {
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute confirm mutation
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
// Update queue item to complete
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
await pool.query(
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_confirm',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip });
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed — leave queue item as pending
|
||||
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Confirm error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/decline
|
||||
// Decline asset from a team via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
@@ -362,7 +241,6 @@ function createCardApiRouter(db, requireAuth) {
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
@@ -371,11 +249,11 @@ function createCardApiRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
const item = rows[0];
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
@@ -384,20 +262,10 @@ function createCardApiRouter(db, requireAuth) {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
@@ -409,80 +277,39 @@ function createCardApiRouter(db, requireAuth) {
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute decline mutation
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
await pool.query(
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_decline',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip });
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Decline error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// POST /queue/:queueItemId/redirect
|
||||
// Redirect asset from one team to another via CARD API.
|
||||
// -------------------------------------------------------------------
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
@@ -490,7 +317,6 @@ function createCardApiRouter(db, requireAuth) {
|
||||
const { queueItemId } = req.params;
|
||||
const { fromTeam, toTeam, assetId } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
@@ -502,11 +328,11 @@ function createCardApiRouter(db, requireAuth) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate queue item
|
||||
const item = await dbGet(db,
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
[queueItemId, req.user.id, 'CARD']
|
||||
);
|
||||
const item = rows[0];
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
@@ -515,20 +341,10 @@ function createCardApiRouter(db, requireAuth) {
|
||||
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
||||
}
|
||||
|
||||
// Step 1: Get owner record for update_token
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
||||
return res.status(ownerResult.status).json(errorBody);
|
||||
@@ -540,71 +356,33 @@ function createCardApiRouter(db, requireAuth) {
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
// Step 2: Execute redirect mutation
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
await dbRun(db,
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
await pool.query(
|
||||
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
||||
[queueItemId]
|
||||
);
|
||||
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_redirect',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip });
|
||||
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
// Mutation failed
|
||||
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
|
||||
console.error('[card-api]', errMsg);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip });
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
console.error('[card-api] Redirect error:', err.message);
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'card_action_failed',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(queueItemId),
|
||||
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,26 +4,16 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
function createFeedbackRouter(db, requireAuth) {
|
||||
function createFeedbackRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
const GITLAB_URL = process.env.GITLAB_URL || '';
|
||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||
|
||||
/**
|
||||
* POST /api/feedback
|
||||
*
|
||||
* Create a GitLab issue from a bug report or feature request.
|
||||
* Available to all authenticated users.
|
||||
*
|
||||
* @body {string} type - "bug" or "feature"
|
||||
* @body {string} title - Issue title
|
||||
* @body {string} description - Issue description
|
||||
* @body {string} [page] - Which dashboard page the user was on
|
||||
*/
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
router.post('/', requireAuth(), async (req, res) => {
|
||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||
|
||||
/**
|
||||
* Find the most severe active finding related to an archived finding.
|
||||
*
|
||||
* A match requires:
|
||||
* - Exact hostname match (case-sensitive)
|
||||
* - The archive title is a case-insensitive substring of the active title, or vice versa
|
||||
* - The active finding ID differs from the archive's finding_id
|
||||
*
|
||||
* @param {Object} archive - Archive record from ivanti_finding_archives
|
||||
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
|
||||
* @returns {{ id: string, title: string, severity: number } | null}
|
||||
*/
|
||||
function findRelatedActive(archive, activeFindings) {
|
||||
const archiveTitle = (archive.finding_title || '').toLowerCase();
|
||||
@@ -34,21 +27,13 @@ function findRelatedActive(archive, activeFindings) {
|
||||
return { id: best.id, title: best.title, severity: best.severity };
|
||||
}
|
||||
|
||||
function createIvantiArchiveRouter(db, requireAuth) {
|
||||
function createIvantiArchiveRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
router.use(requireAuth());
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* List archive records with optional state filtering.
|
||||
*
|
||||
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
||||
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
||||
* @returns {Object} 400 - { error: string } when state param is invalid
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
// GET / — List archive records with optional state filtering
|
||||
router.get('/', async (req, res) => {
|
||||
const { state } = req.query;
|
||||
|
||||
@@ -61,43 +46,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
||||
try {
|
||||
let query = 'SELECT * FROM ivanti_finding_archives';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (state) {
|
||||
query += ' WHERE current_state = ?';
|
||||
query += ` WHERE current_state = $${paramIndex++}`;
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
query += ' ORDER BY last_transition_at DESC';
|
||||
|
||||
const archives = await new Promise((resolve, reject) => {
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
const { rows: archives } = await pool.query(query, params);
|
||||
|
||||
// Fetch and parse active findings cache for related-finding enrichment
|
||||
// Fetch active findings for related-finding enrichment
|
||||
// In the new schema, active findings are in ivanti_findings table
|
||||
let activeFindings = [];
|
||||
try {
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (cacheRow && cacheRow.findings_json) {
|
||||
activeFindings = JSON.parse(cacheRow.findings_json);
|
||||
}
|
||||
const { rows: findingsRows } = await pool.query(
|
||||
`SELECT id, title, host_name AS "hostName", severity FROM ivanti_findings WHERE state = 'open'`
|
||||
);
|
||||
activeFindings = findingsRows;
|
||||
} catch (cacheErr) {
|
||||
console.warn('Failed to load findings cache for related-active matching:', cacheErr);
|
||||
}
|
||||
|
||||
if (!Array.isArray(activeFindings)) {
|
||||
activeFindings = [];
|
||||
console.warn('Failed to load findings for related-active matching:', cacheErr);
|
||||
}
|
||||
|
||||
// Enrich each archive record with related active finding info
|
||||
@@ -113,52 +82,28 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /stats
|
||||
* Summary counts of archive records by lifecycle state.
|
||||
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
||||
*
|
||||
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
// GET /stats — Summary counts by lifecycle state
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
// Count archive records by state
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT current_state, COUNT(*) as count
|
||||
FROM ivanti_finding_archives
|
||||
GROUP BY current_state`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`SELECT current_state, COUNT(*) as count
|
||||
FROM ivanti_finding_archives
|
||||
GROUP BY current_state`
|
||||
);
|
||||
|
||||
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
||||
|
||||
for (const row of rows) {
|
||||
if (stats.hasOwnProperty(row.current_state)) {
|
||||
stats[row.current_state] = row.count;
|
||||
stats[row.current_state] = parseInt(row.count);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
||||
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
||||
// so ACTIVE = live count (all findings currently present in sync results)
|
||||
stats.ACTIVE = liveFindingsCount;
|
||||
// ACTIVE = total live findings count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`
|
||||
);
|
||||
stats.ACTIVE = parseInt(countResult.rows[0].total) || 0;
|
||||
|
||||
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||
|
||||
@@ -169,46 +114,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:findingId/history
|
||||
* Transition history for a specific archived finding, ordered by most recent first.
|
||||
* Returns an empty transitions array if the finding has no archive record.
|
||||
*
|
||||
* @param {string} findingId - Ivanti finding identifier (route param)
|
||||
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
||||
* @returns {Object} 500 - { error: string } on database failure
|
||||
*/
|
||||
// GET /:findingId/history — Transition history for a specific archived finding
|
||||
router.get('/:findingId/history', async (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
|
||||
try {
|
||||
const archive = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?',
|
||||
[findingId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows: archiveRows } = await pool.query(
|
||||
'SELECT id FROM ivanti_finding_archives WHERE finding_id = $1',
|
||||
[findingId]
|
||||
);
|
||||
const archive = archiveRows[0];
|
||||
|
||||
if (!archive) {
|
||||
return res.json({ finding_id: findingId, transitions: [] });
|
||||
}
|
||||
|
||||
const transitions = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT * FROM ivanti_archive_transitions
|
||||
WHERE archive_id = ?
|
||||
ORDER BY transitioned_at DESC`,
|
||||
[archive.id],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows: transitions } = await pool.query(
|
||||
`SELECT * FROM ivanti_archive_transitions
|
||||
WHERE archive_id = $1
|
||||
ORDER BY transitioned_at DESC`,
|
||||
[archive.id]
|
||||
);
|
||||
|
||||
res.json({ finding_id: findingId, transitions });
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
// routes/ivantiTodoQueue.js
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
|
||||
@@ -12,71 +13,34 @@ function isValidVendor(vendor) {
|
||||
return trimmed.length > 0 && trimmed.length <= 200;
|
||||
}
|
||||
|
||||
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
function createIvantiTodoQueueRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue
|
||||
*
|
||||
* Fetch the current user's queue items, ordered by vendor then created_at.
|
||||
*
|
||||
* @returns {Array<Object>} 200 - Array of queue items, each with:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
db.all(
|
||||
`SELECT q.*,
|
||||
o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.user_id = ?
|
||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||
[req.user.id],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching todo queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
// Parse cves_json back to array; prefer overridden hostname
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
// Clean up the extra column from the response
|
||||
parsed.forEach((r) => delete r.override_hostname);
|
||||
res.json(parsed);
|
||||
}
|
||||
);
|
||||
// GET /api/ivanti/todo-queue
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT q.*
|
||||
FROM ivanti_todo_queue q
|
||||
WHERE q.user_id = $1
|
||||
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||
[req.user.id]
|
||||
);
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
res.json(parsed);
|
||||
} catch (err) {
|
||||
console.error('Error fetching todo queue:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/batch
|
||||
*
|
||||
* Add multiple findings to the current user's queue in a single transaction.
|
||||
*
|
||||
* @body {Object[]} findings - Required array of 1–200 finding objects
|
||||
* @body {string} findings[].finding_id - Required, non-empty finding identifier
|
||||
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
|
||||
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
||||
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
||||
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
||||
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - { items: Array<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) => {
|
||||
// POST /api/ivanti/todo-queue/batch
|
||||
router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { findings, workflow_type, vendor } = req.body;
|
||||
|
||||
// --- Validation ---
|
||||
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
|
||||
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
|
||||
}
|
||||
@@ -105,131 +69,70 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
const userId = req.user.id;
|
||||
|
||||
// --- Transactional batch insert ---
|
||||
// Prepare all row values upfront
|
||||
const rows = findings.map((f) => {
|
||||
const findingId = f.finding_id.trim();
|
||||
const title = f.finding_title && typeof f.finding_title === 'string'
|
||||
? f.finding_title.slice(0, 500)
|
||||
: null;
|
||||
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
|
||||
const ipVal = f.ip_address && typeof f.ip_address === 'string'
|
||||
? f.ip_address.trim().slice(0, 64)
|
||||
: null;
|
||||
const hostVal = f.hostname && typeof f.hostname === 'string'
|
||||
? f.hostname.trim().slice(0, 255)
|
||||
: null;
|
||||
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
|
||||
});
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const insertedIds = [];
|
||||
let insertError = null;
|
||||
let remaining = rows.length;
|
||||
const insertedIds = [];
|
||||
for (const f of findings) {
|
||||
const findingId = f.finding_id.trim();
|
||||
const title = f.finding_title && typeof f.finding_title === 'string'
|
||||
? f.finding_title.slice(0, 500) : null;
|
||||
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
|
||||
const ipVal = f.ip_address && typeof f.ip_address === 'string'
|
||||
? f.ip_address.trim().slice(0, 64) : null;
|
||||
const hostVal = f.hostname && typeof f.hostname === 'string'
|
||||
? f.hostname.trim().slice(0, 255) : null;
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
|
||||
rows.forEach((params) => {
|
||||
db.run(
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params,
|
||||
function (err) {
|
||||
if (err && !insertError) {
|
||||
insertError = err;
|
||||
} else if (!err) {
|
||||
insertedIds.push(this.lastID);
|
||||
}
|
||||
remaining--;
|
||||
|
||||
// After all insert callbacks have fired, commit or rollback
|
||||
if (remaining === 0) {
|
||||
if (insertError) {
|
||||
db.run('ROLLBACK', () => {
|
||||
console.error('Batch insert error:', insertError);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
});
|
||||
} else {
|
||||
db.run('COMMIT', (commitErr) => {
|
||||
if (commitErr) {
|
||||
console.error('Batch commit error:', commitErr);
|
||||
db.run('ROLLBACK', () => {});
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Fetch all inserted rows
|
||||
const placeholders = insertedIds.map(() => '?').join(',');
|
||||
db.all(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id IN (${placeholders})`,
|
||||
insertedIds,
|
||||
(fetchErr, fetchedRows) => {
|
||||
if (fetchErr) {
|
||||
console.error('Error fetching inserted batch rows:', fetchErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const items = (fetchedRows || []).map((r) => {
|
||||
const item = {
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
};
|
||||
delete item.override_hostname;
|
||||
return item;
|
||||
});
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'batch_add_to_queue',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: null,
|
||||
details: {
|
||||
count: insertedIds.length,
|
||||
workflow_type: workflow_type,
|
||||
finding_ids: findings.map((f) => f.finding_id.trim()),
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return res.status(201).json({ items });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`,
|
||||
[userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
|
||||
);
|
||||
insertedIds.push(rows[0].id);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Fetch all inserted rows
|
||||
const { rows: fetchedRows } = await pool.query(
|
||||
`SELECT * FROM ivanti_todo_queue WHERE id = ANY($1)`,
|
||||
[insertedIds]
|
||||
);
|
||||
|
||||
const items = fetchedRows.map((r) => ({
|
||||
...r,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'batch_add_to_queue',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: null,
|
||||
details: {
|
||||
count: insertedIds.length,
|
||||
workflow_type: workflow_type,
|
||||
finding_ids: findings.map((f) => f.finding_id.trim()),
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(201).json({ items });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Batch insert error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue
|
||||
*
|
||||
* Add a single finding to the current user's queue.
|
||||
*
|
||||
* @body {string} finding_id - Required, non-empty finding identifier
|
||||
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
||||
* @body {string[]} [cves] - Optional array of CVE identifiers
|
||||
* @body {string} [ip_address] - Optional IP address (max 64 chars)
|
||||
* @body {string} [hostname] - Optional hostname (max 255 chars)
|
||||
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - Created queue item with parsed cves array:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||
* @returns {Object} 400 - { error: string } on validation failure
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
// POST /api/ivanti/todo-queue
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
|
||||
|
||||
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||
@@ -238,7 +141,6 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
// Vendor is required for FP and Archer, optional for CARD/GRANITE
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
@@ -251,61 +153,30 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
|
||||
const title = finding_title && typeof finding_title === 'string'
|
||||
? finding_title.slice(0, 500)
|
||||
: null;
|
||||
? finding_title.slice(0, 500) : null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error adding to queue:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[this.lastID],
|
||||
(err2, row) => {
|
||||
if (err2 || !row) {
|
||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||
}
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
|
||||
);
|
||||
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error adding to queue:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/ivanti/todo-queue/:id
|
||||
*
|
||||
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
|
||||
*
|
||||
* @param {string} id - Queue item ID (URL parameter)
|
||||
* @body {string} [vendor] - New vendor string (max 200 chars)
|
||||
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} [status] - One of 'pending', 'complete'
|
||||
*
|
||||
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
// PUT /api/ivanti/todo-queue/:id
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, workflow_type, status } = req.body;
|
||||
|
||||
@@ -319,248 +190,160 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||
}
|
||||
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (vendor !== undefined) {
|
||||
updates.push('vendor = ?');
|
||||
params.push(vendor.trim());
|
||||
}
|
||||
if (workflow_type !== undefined) {
|
||||
updates.push('workflow_type = ?');
|
||||
params.push(workflow_type);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(id, req.user.id);
|
||||
|
||||
db.run(
|
||||
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||
params,
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
db.get(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[id],
|
||||
(err3, row) => {
|
||||
if (err3 || !row) {
|
||||
return res.json({ message: 'Queue item updated.' });
|
||||
}
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
try {
|
||||
const { rows: existingRows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
if (!existingRows[0]) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
);
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (vendor !== undefined) {
|
||||
updates.push(`vendor = $${paramIndex++}`);
|
||||
params.push(vendor.trim());
|
||||
}
|
||||
if (workflow_type !== undefined) {
|
||||
updates.push(`workflow_type = $${paramIndex++}`);
|
||||
params.push(workflow_type);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push(`status = $${paramIndex++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()');
|
||||
params.push(id, req.user.id);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex}`,
|
||||
params
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1', [id]
|
||||
);
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirect a completed queue item to a different workflow type.
|
||||
* Creates a new pending item copying finding data from the original.
|
||||
*
|
||||
* @param {string} id - Original queue item ID (URL parameter)
|
||||
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE'
|
||||
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - Newly created queue item with parsed cves array
|
||||
* @returns {Object} 400 - { error: string } on validation failure or item not complete
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
// POST /api/ivanti/todo-queue/:id/redirect
|
||||
router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { workflow_type, vendor } = req.body;
|
||||
|
||||
// --- Validation ---
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
if (!isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||
}
|
||||
|
||||
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
|
||||
// --- Fetch original item scoped to current user ---
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, original) => {
|
||||
if (err) {
|
||||
console.error('Error fetching queue item for redirect:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
}
|
||||
|
||||
// --- INSERT new row copying finding data from original ---
|
||||
db.run(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
|
||||
function (insertErr) {
|
||||
if (insertErr) {
|
||||
console.error('Error inserting redirected queue item:', insertErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const newId = this.lastID;
|
||||
|
||||
// --- Fetch the inserted row ---
|
||||
db.get(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[newId],
|
||||
(fetchErr, row) => {
|
||||
if (fetchErr || !row) {
|
||||
console.error('Error fetching redirected queue item:', fetchErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
new_item_id: newId,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
return res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
try {
|
||||
const { rows: origRows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
const original = origRows[0];
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
);
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
new_item_id: rows[0].id,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error redirecting queue item:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/completed
|
||||
*
|
||||
* Bulk-delete all completed items for the current user.
|
||||
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
|
||||
*
|
||||
* @returns {Object} 200 - { message: string, deleted: number }
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
db.run(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||
[req.user.id],
|
||||
function (err) {
|
||||
if (err) {
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Completed items cleared.', deleted: this.changes });
|
||||
}
|
||||
);
|
||||
// DELETE /api/ivanti/todo-queue/completed
|
||||
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/:id
|
||||
*
|
||||
* Delete a single queue item — scoped to current user.
|
||||
*
|
||||
* @param {string} id - Queue item ID (URL parameter)
|
||||
*
|
||||
* @returns {Object} 200 - { message: string }
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
// DELETE /api/ivanti/todo-queue/:id
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
function (err2) {
|
||||
if (err2) {
|
||||
console.error(err2);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json({ message: 'Queue item deleted.' });
|
||||
}
|
||||
);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
|
||||
[id, req.user.id]
|
||||
);
|
||||
res.json({ message: 'Queue item deleted.' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
// Ivanti / RiskSense Workflow Routes
|
||||
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||
// Data is cached in PostgreSQL and refreshed on a daily schedule or on-demand.
|
||||
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||
// Core sync — calls Ivanti API, stores result in PostgreSQL
|
||||
// ---------------------------------------------------------------------------
|
||||
function initTable(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
workflows_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core sync — calls Ivanti API, stores result in SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
async function syncWorkflows(db) {
|
||||
async function syncWorkflows() {
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||
@@ -50,12 +21,10 @@ async function syncWorkflows(db) {
|
||||
if (!apiKey) {
|
||||
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||
console.warn('[Ivanti]', errMsg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[errMsg], resolve
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||
[errMsg]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,7 +76,6 @@ async function syncWorkflows(db) {
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
|
||||
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||
let total = 0;
|
||||
let workflows = [];
|
||||
|
||||
@@ -127,95 +95,89 @@ async function syncWorkflows(db) {
|
||||
total = data.length;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[total, JSON.stringify(workflows)],
|
||||
(err) => { if (err) reject(err); else resolve(); }
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state
|
||||
SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
|
||||
WHERE id=1`,
|
||||
[total, JSON.stringify(workflows)]
|
||||
);
|
||||
|
||||
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error';
|
||||
console.error('[Ivanti] Sync failed:', msg);
|
||||
await new Promise((resolve) => {
|
||||
db.run(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[msg], resolve
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
||||
[msg]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||
// ---------------------------------------------------------------------------
|
||||
function scheduleSync(db) {
|
||||
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||
if (err || !row || !row.synced_at) {
|
||||
syncWorkflows(db);
|
||||
async function scheduleSync() {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1');
|
||||
const row = rows[0];
|
||||
if (!row || !row.synced_at) {
|
||||
syncWorkflows();
|
||||
} else {
|
||||
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||
const lastSync = new Date(row.synced_at);
|
||||
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSince >= 24) {
|
||||
syncWorkflows(db);
|
||||
syncWorkflows();
|
||||
} else {
|
||||
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Ivanti] Schedule check failed:', err);
|
||||
syncWorkflows();
|
||||
}
|
||||
|
||||
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||
setInterval(() => syncWorkflows(), SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper — read current state from DB and return as JSON-ready object
|
||||
// ---------------------------------------------------------------------------
|
||||
function readState(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||
async function readState() {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return { total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null };
|
||||
|
||||
let workflows = [];
|
||||
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
let workflows = [];
|
||||
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||
|
||||
resolve({
|
||||
total: row.total || 0,
|
||||
workflows,
|
||||
synced_at: row.synced_at,
|
||||
sync_status: row.sync_status,
|
||||
error_message: row.error_message
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
return {
|
||||
total: row.total || 0,
|
||||
workflows,
|
||||
synced_at: row.synced_at,
|
||||
sync_status: row.sync_status,
|
||||
error_message: row.error_message
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||
function createIvantiWorkflowsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||
initTable(db)
|
||||
.then(() => scheduleSync(db))
|
||||
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||
// Kick off scheduler (fire-and-forget on startup)
|
||||
scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
router.use(requireAuth());
|
||||
|
||||
// GET / — return cached data (fast, no external call)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
res.json(await readState());
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Database error reading sync state' });
|
||||
}
|
||||
@@ -223,9 +185,9 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||
|
||||
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
await syncWorkflows(db);
|
||||
await syncWorkflows();
|
||||
try {
|
||||
res.json(await readState(db));
|
||||
res.json(await readState());
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// - Rate limits enforced client-side (1440/day, 60/min burst)
|
||||
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
@@ -27,24 +28,14 @@ function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createJiraTicketsRouter(db) {
|
||||
function createJiraTicketsRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Jira API integration endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira/connection-test
|
||||
*
|
||||
* Verify Jira credentials and connectivity by testing the configured
|
||||
* Jira API connection. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { connected: true, user: { name, ... } }
|
||||
* @returns {object} 502 - { connected: false, status, error } | { connected: false, error }
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/connection-test', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
}
|
||||
@@ -52,7 +43,7 @@ function createJiraTicketsRouter(db) {
|
||||
try {
|
||||
const result = await jiraApi.testConnection();
|
||||
if (result.ok) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_connection_test',
|
||||
@@ -69,32 +60,11 @@ function createJiraTicketsRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/rate-limit
|
||||
*
|
||||
* Return current Jira API rate limit usage. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
||||
*/
|
||||
router.get('/rate-limit', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
||||
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira/lookup/:issueKey
|
||||
*
|
||||
* Fetch a single issue from Jira by its issue key (e.g., PROJECT-123).
|
||||
* Uses explicit `?fields=` parameter per Charter Jira REST API requirement.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @returns {object} 400 - { error } when issue key format is invalid
|
||||
* @returns {object} 404 - { error } when issue not found in Jira
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.get('/lookup/:issueKey', requireAuth(db), async (req, res) => {
|
||||
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
@@ -132,27 +102,7 @@ function createJiraTicketsRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/create-in-jira
|
||||
*
|
||||
* Create a new issue in Jira via the REST API and insert a linked local
|
||||
* record in the `jira_tickets` table. Requires Admin or Standard_User group.
|
||||
* Subject to 2s write delay enforced by jiraApi.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} summary - Issue summary (required, max 255 chars)
|
||||
* @body {string} [description] - Issue description
|
||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
||||
* @returns {object} 201 - { id, ticket_key, jira_url, message }
|
||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local save failed
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/create-in-jira', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
@@ -201,191 +151,150 @@ function createJiraTicketsRouter(db) {
|
||||
? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
|
||||
: null;
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error saving local Jira ticket record:', err);
|
||||
return res.status(207).json({
|
||||
warning: 'Issue created in Jira but local record failed to save.',
|
||||
jira_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||
RETURNING id`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
res.status(201).json({
|
||||
id: rows[0].id,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
} catch (dbErr) {
|
||||
console.error('Error saving local Jira ticket record:', dbErr);
|
||||
return res.status(207).json({
|
||||
warning: 'Issue created in Jira but local record failed to save.',
|
||||
jira_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
error: dbErr.message
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/sync-all
|
||||
*
|
||||
* Bulk-sync all local tickets that have a Jira key by fetching their
|
||||
* latest status from Jira. Uses a single JQL bulk search per batch
|
||||
* instead of one GET per ticket (Charter-compliant). Stops early if
|
||||
* the rate limit budget is running low. Admin only.
|
||||
*
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/sync-all', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
db.all(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''",
|
||||
[],
|
||||
async (err, tickets) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
try {
|
||||
const { rows: tickets } = await pool.query(
|
||||
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
|
||||
);
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
}
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
|
||||
}
|
||||
|
||||
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
|
||||
|
||||
// Batch keys into groups of 100 for JQL (avoid overly long queries)
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
|
||||
batches.push(tickets.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
// Check rate limit before each batch
|
||||
const rateStatus = jiraApi.getRateLimitStatus();
|
||||
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
|
||||
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
|
||||
results.skipped += remaining;
|
||||
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
|
||||
break;
|
||||
const keys = batch.map(t => t.ticket_key);
|
||||
try {
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
results.skipped += batch.length;
|
||||
results.errors.push('Jira rate limit hit during sync.');
|
||||
break;
|
||||
}
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search failed: HTTP ${result.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = batch.map(t => t.ticket_key);
|
||||
try {
|
||||
// Bulk JQL search — Charter-compliant, single request per batch
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
results.skipped += batch.length;
|
||||
results.errors.push('Jira rate limit hit during sync.');
|
||||
break;
|
||||
}
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search failed: HTTP ${result.status}`);
|
||||
const issueMap = {};
|
||||
for (const issue of (result.data.issues || [])) {
|
||||
issueMap[issue.key] = issue;
|
||||
}
|
||||
|
||||
for (const ticket of batch) {
|
||||
const issue = issueMap[ticket.ticket_key];
|
||||
if (!issue) {
|
||||
results.unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a map of key → Jira issue data
|
||||
const issueMap = {};
|
||||
for (const issue of (result.data.issues || [])) {
|
||||
issueMap[issue.key] = issue;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id]
|
||||
);
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
results.failed++;
|
||||
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
||||
}
|
||||
|
||||
// Update each local ticket from the search results
|
||||
for (const ticket of batch) {
|
||||
const issue = issueMap[ticket.ticket_key];
|
||||
if (!issue) {
|
||||
// Issue not returned — either not updated in last 24h or not found
|
||||
results.unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id],
|
||||
(updateErr) => updateErr ? reject(updateErr) : resolve()
|
||||
);
|
||||
});
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
results.failed++;
|
||||
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
|
||||
}
|
||||
}
|
||||
} catch (searchErr) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search error: ${searchErr.message}`);
|
||||
}
|
||||
} catch (searchErr) {
|
||||
results.failed += batch.length;
|
||||
results.errors.push(`Batch search error: ${searchErr.message}`);
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: results,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
}
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_sync_all',
|
||||
entityType: 'jira_integration',
|
||||
entityId: null,
|
||||
details: results,
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira/:id/sync
|
||||
*
|
||||
* Sync a single local ticket with Jira by fetching the latest status,
|
||||
* summary, and mapping the Jira status to the local three-state model.
|
||||
* Uses getIssue with explicit fields (Charter-compliant GET).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @returns {object} 400 - { error } when ticket has no Jira key
|
||||
* @returns {object} 404 - { error } when local ticket not found
|
||||
* @returns {object} 429 - { error } when Jira rate limit exceeded
|
||||
* @returns {object} 500 - { error } on database error
|
||||
* @returns {object} 502 - { error, details } on Jira API failure
|
||||
* @returns {object} 503 - { error } when Jira API is not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
@@ -393,117 +302,83 @@ function createJiraTicketsRouter(db) {
|
||||
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jiraApi.getIssue(ticket.ticket_key);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
||||
const result = await jiraApi.getIssue(ticket.ticket_key);
|
||||
if (!result.ok) {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
db.run(
|
||||
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[jiraSummary, localStatus, jiraStatus, id],
|
||||
function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating synced ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Ticket synced with Jira',
|
||||
ticket_key: ticket.ticket_key,
|
||||
jira_status: jiraStatus,
|
||||
local_status: localStatus,
|
||||
summary: jiraSummary
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: err.message });
|
||||
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
|
||||
}
|
||||
});
|
||||
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_sync',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Ticket synced with Jira',
|
||||
ticket_key: ticket.ticket_key,
|
||||
jira_status: jiraStatus,
|
||||
local_status: localStatus,
|
||||
summary: jiraSummary
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local CRUD endpoints (migrated from server.js)
|
||||
// Local CRUD endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira
|
||||
*
|
||||
* List all local JIRA ticket records with optional filters.
|
||||
* Results are ordered by `created_at` descending.
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by CVE ID
|
||||
* @query {string} [vendor] - Filter by vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @returns {object[]} 200 - Array of jira_tickets rows
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (cve_id) {
|
||||
query += ' AND cve_id = ?';
|
||||
query += ` AND cve_id = $${paramIndex++}`;
|
||||
params.push(cve_id);
|
||||
}
|
||||
if (vendor) {
|
||||
query += ' AND vendor = ?';
|
||||
query += ` AND vendor = $${paramIndex++}`;
|
||||
params.push(vendor);
|
||||
}
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
query += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira
|
||||
*
|
||||
* Create a local JIRA ticket record (manual entry, no Jira API call).
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
|
||||
* @body {string} vendor - Vendor name (required, max 200 chars)
|
||||
* @body {string} ticket_key - Jira issue key (required, max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error } on validation failure
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
@@ -527,51 +402,35 @@ function createJiraTicketsRouter(db) {
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
res.status(201).json({
|
||||
id: rows[0].id,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jira/:id
|
||||
*
|
||||
* Update a local JIRA ticket record. Only provided fields are updated.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @body {string} [ticket_key] - Jira issue key (max 50 chars)
|
||||
* @body {string} [url] - URL to the Jira issue (max 500 chars, or null)
|
||||
* @body {string} [summary] - Ticket summary (max 500 chars, or null)
|
||||
* @body {string} [status] - Ticket status: Open, In Progress, or Closed
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error } on validation failure or no fields provided
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
@@ -590,70 +449,56 @@ function createJiraTicketsRouter(db) {
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
|
||||
if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
|
||||
if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
const result = await pool.query(
|
||||
`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error updating JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jira/:id
|
||||
*
|
||||
* Delete a local JIRA ticket record. Admins bypass all restrictions.
|
||||
* Standard_User can only delete tickets they created, and cannot delete
|
||||
* tickets linked to active compliance items.
|
||||
*
|
||||
* @param {number} id - Local jira_tickets row ID (path parameter)
|
||||
* @returns {object} 200 - { message }
|
||||
* @returns {object} 403 - { error } when ownership check fails or ticket is linked to compliance
|
||||
* @returns {object} 404 - { error } when ticket not found
|
||||
* @returns {object} 500 - { error } on database error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
|
||||
const ticket = rows[0];
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||
}
|
||||
@@ -670,54 +515,48 @@ function createJiraTicketsRouter(db) {
|
||||
|
||||
// Standard_User: compliance linkage check
|
||||
const ticketKey = ticket.ticket_key;
|
||||
db.all(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||
[`%${ticketKey}%`],
|
||||
(compErr, compLinks) => {
|
||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||
compLinks = [];
|
||||
} else if (compErr) {
|
||||
console.error(compErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
try {
|
||||
const { rows: compLinks } = await pool.query(
|
||||
`SELECT ci.id, ci.extra_json
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||
WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
|
||||
[`%${ticketKey}%`]
|
||||
);
|
||||
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(ticketKey);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
|
||||
return performJiraDelete();
|
||||
}
|
||||
);
|
||||
|
||||
function performJiraDelete() {
|
||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
const isLinked = (compLinks || []).some(cl => {
|
||||
const json = cl.extra_json || '';
|
||||
return json.includes(ticketKey);
|
||||
});
|
||||
|
||||
if (isLinked) {
|
||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||
}
|
||||
} catch (compErr) {
|
||||
if (!compErr.message.includes('does not exist')) throw compErr;
|
||||
}
|
||||
});
|
||||
|
||||
return performJiraDelete();
|
||||
|
||||
async function performJiraDelete() {
|
||||
await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
@@ -727,10 +566,6 @@ function createJiraTicketsRouter(db) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Jira workflow status name to the local three-state model.
|
||||
* Jira statuses vary by project workflow, so this uses broad categories.
|
||||
*/
|
||||
function mapJiraStatusToLocal(jiraStatus) {
|
||||
if (!jiraStatus) return 'Open';
|
||||
const lower = jiraStatus.toLowerCase();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
function createKnowledgeBaseRouter(db, upload) {
|
||||
function createKnowledgeBaseRouter(upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// Helper to sanitize filename
|
||||
@@ -39,20 +40,8 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return ALLOWED_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/knowledge-base/upload
|
||||
* Upload a new knowledge base document.
|
||||
*
|
||||
* @body {string} title - Article title (required)
|
||||
* @body {string} [description] - Article description
|
||||
* @body {string} [category] - Article category (defaults to 'General')
|
||||
* @body {File} file - The document file to upload (multipart/form-data)
|
||||
*
|
||||
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
|
||||
* @response 400 - { error: string } - Missing title, no file, or invalid file type
|
||||
* @response 500 - { error: string } - Database or filesystem error
|
||||
*/
|
||||
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
// POST /api/knowledge-base/upload
|
||||
router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('[KB Upload] Multer error:', err);
|
||||
@@ -70,7 +59,6 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
const uploadedFile = req.file;
|
||||
const { title, description, category } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !title.trim()) {
|
||||
console.error('[KB Upload] Error: Title is missing');
|
||||
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||
@@ -81,7 +69,6 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!isValidFileType(uploadedFile.originalname)) {
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
return res.status(400).json({ error: 'File type not allowed' });
|
||||
@@ -96,172 +83,121 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
const filePath = path.join(kbDir, filename);
|
||||
|
||||
try {
|
||||
// Keep file in temp location until DB insert succeeds
|
||||
// Check if slug already exists
|
||||
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||
if (err) {
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error checking slug:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
const { rows: existingRows } = await pool.query(
|
||||
'SELECT id FROM knowledge_base WHERE slug = $1', [slug]
|
||||
);
|
||||
|
||||
const finalSlug = existingRows.length > 0 ? `${slug}-${timestamp}` : slug;
|
||||
|
||||
// Insert new knowledge base entry
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO knowledge_base (
|
||||
title, slug, description, category, file_path, file_name,
|
||||
file_type, file_size, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`,
|
||||
[
|
||||
title.trim(),
|
||||
finalSlug,
|
||||
description || null,
|
||||
category || 'General',
|
||||
filePath,
|
||||
sanitizedName,
|
||||
uploadedFile.mimetype,
|
||||
uploadedFile.size,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
|
||||
// DB insert succeeded — now move file to permanent location
|
||||
try {
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
} catch (moveErr) {
|
||||
console.error('Error moving file to permanent location:', moveErr);
|
||||
}
|
||||
|
||||
// If slug exists, append timestamp to make it unique
|
||||
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'CREATE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(rows[0].id),
|
||||
details: { title: title.trim(), filename: sanitizedName },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// Insert new knowledge base entry
|
||||
const insertSql = `
|
||||
INSERT INTO knowledge_base (
|
||||
title, slug, description, category, file_path, file_name,
|
||||
file_type, file_size, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(
|
||||
insertSql,
|
||||
[
|
||||
title.trim(),
|
||||
finalSlug,
|
||||
description || null,
|
||||
category || 'General',
|
||||
filePath,
|
||||
sanitizedName,
|
||||
uploadedFile.mimetype,
|
||||
uploadedFile.size,
|
||||
req.user.id
|
||||
],
|
||||
function (err) {
|
||||
if (err) {
|
||||
fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error inserting knowledge base entry:', err);
|
||||
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||
}
|
||||
|
||||
// DB insert succeeded — now move file to permanent location
|
||||
try {
|
||||
if (!fs.existsSync(kbDir)) {
|
||||
fs.mkdirSync(kbDir, { recursive: true });
|
||||
}
|
||||
fs.renameSync(uploadedFile.path, filePath);
|
||||
} catch (moveErr) {
|
||||
console.error('Error moving file to permanent location:', moveErr);
|
||||
// File is orphaned in temp but DB record exists — log and continue
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'CREATE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(this.lastID),
|
||||
details: { title: title.trim(), filename: sanitizedName },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: this.lastID,
|
||||
title: title.trim(),
|
||||
slug: finalSlug,
|
||||
category: category || 'General'
|
||||
});
|
||||
}
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
id: rows[0].id,
|
||||
title: title.trim(),
|
||||
slug: finalSlug,
|
||||
category: category || 'General'
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up temp file on error
|
||||
if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
|
||||
console.error('Error uploading knowledge base document:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge-base
|
||||
* List all knowledge base articles.
|
||||
*
|
||||
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
ORDER BY kb.created_at DESC
|
||||
`;
|
||||
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching knowledge base articles:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||
}
|
||||
|
||||
// GET /api/knowledge-base
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
ORDER BY kb.created_at DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching knowledge base articles:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch articles' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge-base/:id
|
||||
* Get a single article's details by ID.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
|
||||
* @response 404 - { error: 'Article not found' }
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id', requireAuth(db), (req, res) => {
|
||||
// GET /api/knowledge-base/:id
|
||||
router.get('/:id', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
WHERE kb.id = ?
|
||||
`;
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_username
|
||||
FROM knowledge_base kb
|
||||
LEFT JOIN users u ON kb.created_by = u.id
|
||||
WHERE kb.id = $1
|
||||
`, [id]);
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
if (!rows[0]) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
}
|
||||
|
||||
res.json(row);
|
||||
});
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error fetching article:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/content
|
||||
* Get document content for inline display. Returns the raw file with appropriate
|
||||
* Content-Type headers. Markdown and text files are served as text/plain.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
|
||||
* @response 404 - { error: string } - Article or file not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||
// GET /api/knowledge-base/:id/content
|
||||
router.get('/:id/content', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
|
||||
);
|
||||
const row = rows[0];
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
@@ -271,8 +207,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'VIEW_KB_ARTICLE',
|
||||
@@ -282,10 +217,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
// Determine content type for inline display
|
||||
let contentType = row.file_type || 'application/octet-stream';
|
||||
|
||||
// For markdown files, send as plain text so frontend can parse it
|
||||
if (row.file_name.endsWith('.md')) {
|
||||
contentType = 'text/plain; charset=utf-8';
|
||||
} else if (row.file_name.endsWith('.txt')) {
|
||||
@@ -294,36 +226,26 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
|
||||
const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||
res.setHeader('Content-Type', contentType);
|
||||
// Use inline instead of attachment to allow browser to display
|
||||
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
|
||||
// Allow iframe embedding from frontend origin
|
||||
res.removeHeader('X-Frame-Options');
|
||||
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge-base/:id/download
|
||||
* Download a knowledge base document as an attachment.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - File download with Content-Disposition: attachment header
|
||||
* @response 404 - { error: string } - Article or file not found
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||
// GET /api/knowledge-base/:id/download
|
||||
router.get('/:id/download', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
|
||||
);
|
||||
const row = rows[0];
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Document not found' });
|
||||
@@ -333,8 +255,7 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return res.status(404).json({ error: 'File not found on disk' });
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'DOWNLOAD_KB_ARTICLE',
|
||||
@@ -348,31 +269,21 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
||||
res.sendFile(row.file_path);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching document:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch document' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge-base/:id
|
||||
* Delete a knowledge base article and its associated file.
|
||||
* Standard_User can only delete articles they created. Admin can delete any article.
|
||||
*
|
||||
* @param {string} id - Article ID (route parameter)
|
||||
*
|
||||
* @response 200 - { success: true }
|
||||
* @response 403 - { error: string } - Ownership check failed for Standard_User
|
||||
* @response 404 - { error: 'Article not found' }
|
||||
* @response 500 - { error: string }
|
||||
*/
|
||||
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
// DELETE /api/knowledge-base/:id
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
|
||||
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching article for deletion:', err);
|
||||
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT file_path, title, created_by FROM knowledge_base WHERE id = $1', [id]
|
||||
);
|
||||
const row = rows[0];
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Article not found' });
|
||||
@@ -383,32 +294,28 @@ function createKnowledgeBaseRouter(db, upload) {
|
||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
return res.status(500).json({ error: 'Failed to delete article' });
|
||||
}
|
||||
await pool.query('DELETE FROM knowledge_base WHERE id = $1', [id]);
|
||||
|
||||
// Delete file
|
||||
if (fs.existsSync(row.file_path)) {
|
||||
fs.unlinkSync(row.file_path);
|
||||
}
|
||||
// Delete file
|
||||
if (fs.existsSync(row.file_path)) {
|
||||
fs.unlinkSync(row.file_path);
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'DELETE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(id),
|
||||
details: { title: row.title },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'DELETE_KB_ARTICLE',
|
||||
entityType: 'knowledge_base',
|
||||
entityId: String(id),
|
||||
details: { title: row.title },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting article:', err);
|
||||
res.status(500).json({ error: 'Failed to delete article' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// NVD CVE Lookup Routes
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
|
||||
function createNvdLookupRouter(db, requireAuth) {
|
||||
function createNvdLookupRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
router.use(requireAuth());
|
||||
|
||||
// Lookup CVE details from NVD API 2.0
|
||||
router.get('/lookup/:cveId', async (req, res) => {
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
// User Management Routes (Admin only)
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const pool = require('../db');
|
||||
const { validateTeams } = require('../helpers/teams');
|
||||
|
||||
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
router.use(requireAuth(), requireGroup('Admin'));
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows: users } = await pool.query(
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC`
|
||||
);
|
||||
// Parse bu_teams into teams array for each user
|
||||
const usersWithTeams = users.map(u => ({
|
||||
...u,
|
||||
@@ -37,17 +32,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
// Get single user
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
`SELECT id, username, email, user_group AS 'group', bu_teams, is_active, created_at, last_login
|
||||
FROM users WHERE id = ?`,
|
||||
[req.params.id],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
|
||||
FROM users WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
@@ -90,19 +81,16 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, user_group, bu_teams)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, userGroup, teamsStr],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID });
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, user_group, bu_teams)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
[username, email, passwordHash, userGroup, teamsStr]
|
||||
);
|
||||
|
||||
logAudit(db, {
|
||||
const result = rows[0];
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_create',
|
||||
@@ -125,7 +113,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
if (err.code === '23505') { // Postgres unique violation
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
@@ -165,16 +153,12 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
try {
|
||||
// Fetch current user record before update (needed for group change audit)
|
||||
const currentUser = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT user_group, bu_teams FROM users WHERE id = ?',
|
||||
[userId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
const { rows: currentRows } = await pool.query(
|
||||
'SELECT user_group, bu_teams FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
const currentUser = currentRows[0];
|
||||
|
||||
if (!currentUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
@@ -182,30 +166,31 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (username) {
|
||||
updates.push('username = ?');
|
||||
updates.push(`username = $${paramIndex++}`);
|
||||
values.push(username);
|
||||
}
|
||||
if (email) {
|
||||
updates.push('email = ?');
|
||||
updates.push(`email = $${paramIndex++}`);
|
||||
values.push(email);
|
||||
}
|
||||
if (password) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
updates.push('password_hash = ?');
|
||||
updates.push(`password_hash = $${paramIndex++}`);
|
||||
values.push(passwordHash);
|
||||
}
|
||||
if (group) {
|
||||
updates.push('user_group = ?');
|
||||
updates.push(`user_group = $${paramIndex++}`);
|
||||
values.push(group);
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push('is_active = ?');
|
||||
values.push(is_active ? 1 : 0);
|
||||
updates.push(`is_active = $${paramIndex++}`);
|
||||
values.push(is_active);
|
||||
}
|
||||
if (typeof bu_teams === 'string') {
|
||||
updates.push('bu_teams = ?');
|
||||
updates.push(`bu_teams = $${paramIndex++}`);
|
||||
values.push(bu_teams);
|
||||
}
|
||||
|
||||
@@ -215,16 +200,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
values.push(userId);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
|
||||
values,
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
|
||||
const updatedFields = {};
|
||||
if (username) updatedFields.username = username;
|
||||
@@ -234,7 +213,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
if (password) updatedFields.password_changed = true;
|
||||
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_update',
|
||||
@@ -246,7 +225,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
// Log specific audit entry for group changes
|
||||
if (group && group !== currentUser.user_group) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_group_change',
|
||||
@@ -262,7 +241,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
// Log specific audit entry for bu_teams changes
|
||||
if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) {
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_teams_change',
|
||||
@@ -278,15 +257,13 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
// If user was deactivated, delete their sessions
|
||||
if (is_active === false) {
|
||||
await new Promise((resolve) => {
|
||||
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
||||
});
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
|
||||
}
|
||||
|
||||
res.json({ message: 'User updated successfully' });
|
||||
} catch (err) {
|
||||
console.error('Update user error:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
if (err.code === '23505') { // Postgres unique violation
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
@@ -304,31 +281,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
|
||||
try {
|
||||
// Look up the user before deleting
|
||||
const targetUser = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
const { rows: userRows } = await pool.query(
|
||||
'SELECT username FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
const targetUser = userRows[0];
|
||||
|
||||
// Delete sessions first (foreign key)
|
||||
await new Promise((resolve) => {
|
||||
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
||||
});
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
|
||||
|
||||
// Delete user
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ changes: this.changes });
|
||||
});
|
||||
});
|
||||
const result = await pool.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'user_delete',
|
||||
|
||||
1165
backend/server.js
1165
backend/server.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user