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