Add per-metric remediation plans and improve CI pipeline
Per-metric remediation plan scoping (GitLab issue #19): - Add metric_id column to compliance_item_history table (migration) - Extend PATCH /items/:hostname/metadata to accept metric_id/metric_ids for targeting specific metrics instead of all active items - Add MetricChipSelector UI in detail panel for choosing which metrics to apply resolution_date and remediation_plan changes to - Display per-metric labels (MetricChip or 'All metrics') on history entries - Backward compatible: omitting metric_ids preserves hostname-level behavior CI/CD pipeline improvements: - Add migration idempotency integration test (runs against real Postgres) - Add post-deploy smoke tests for compliance and VCL endpoints - Bump lint --max-warnings from 10 to 25 - Configure varsIgnorePattern for _ prefix convention on unused vars Closes #19
This commit is contained in:
@@ -665,7 +665,7 @@ function createComplianceRouter(upload) {
|
||||
let history = [];
|
||||
try {
|
||||
const { rows: historyRows } = await pool.query(
|
||||
`SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
`SELECT id, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
||||
[hostname]
|
||||
);
|
||||
@@ -943,13 +943,14 @@ function createComplianceRouter(upload) {
|
||||
|
||||
/**
|
||||
* PATCH /items/:hostname/metadata
|
||||
* Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname.
|
||||
* Updates resolution_date and/or remediation_plan for active compliance items matching a hostname.
|
||||
* Supports optional per-metric scoping via metric_id (single) or metric_ids (array).
|
||||
* Records field-level change history in compliance_item_history for each modified field.
|
||||
*
|
||||
* @param hostname — the device hostname
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null }
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null, metric_id?: string, metric_ids?: string[] }
|
||||
* @response 200 { updated: number }
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, or no fields provided
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, no fields provided, or invalid metric_id
|
||||
* @response 404 { error } — device not found
|
||||
* @response 500 { error } — update failure
|
||||
*/
|
||||
@@ -957,7 +958,7 @@ function createComplianceRouter(upload) {
|
||||
const hostname = req.params.hostname;
|
||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||
|
||||
const { resolution_date, remediation_plan, change_reason } = req.body;
|
||||
const { resolution_date, remediation_plan, change_reason, metric_id, metric_ids } = req.body;
|
||||
|
||||
// Validate resolution_date: must be a valid ISO date string or null
|
||||
if (resolution_date !== undefined && resolution_date !== null) {
|
||||
@@ -979,6 +980,31 @@ function createComplianceRouter(upload) {
|
||||
return res.status(400).json({ error: 'Change reason exceeds 500 characters' });
|
||||
}
|
||||
|
||||
// Resolve metric scoping: metric_ids takes precedence over metric_id
|
||||
let resolvedMetricIds = null; // null means hostname-level (no metric scoping)
|
||||
if (metric_ids !== undefined) {
|
||||
if (!Array.isArray(metric_ids)) return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||
if (metric_ids.length === 0) return res.status(400).json({ error: 'metric_ids must contain at least one entry' });
|
||||
for (let i = 0; i < metric_ids.length; i++) {
|
||||
const mid = metric_ids[i];
|
||||
if (!mid || typeof mid !== 'string' || mid.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (mid.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
}
|
||||
resolvedMetricIds = metric_ids;
|
||||
} else if (metric_id !== undefined && metric_id !== null) {
|
||||
if (typeof metric_id !== 'string' || metric_id.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (metric_id.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
resolvedMetricIds = [metric_id];
|
||||
}
|
||||
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
let paramIdx = 1;
|
||||
@@ -1000,69 +1026,148 @@ function createComplianceRouter(upload) {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get current values before updating
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
||||
|
||||
// Insert history for each changed field
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
if (resolvedMetricIds !== null) {
|
||||
// --- Per-metric scoping path ---
|
||||
// Validate that each metric_id corresponds to an active compliance_item for this hostname
|
||||
const { rows: activeMetricRows } = await client.query(
|
||||
`SELECT metric_id, resolution_date, remediation_plan
|
||||
FROM compliance_items
|
||||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'`,
|
||||
[hostname, resolvedMetricIds]
|
||||
);
|
||||
|
||||
const activeMetricMap = new Map();
|
||||
for (const row of activeMetricRows) {
|
||||
activeMetricMap.set(row.metric_id, row);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
|
||||
// Check for invalid metric_ids
|
||||
for (const mid of resolvedMetricIds) {
|
||||
if (!activeMetricMap.has(mid)) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(400).json({ error: `Invalid metric_id: ${mid} — no active compliance item found` });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert history per metric per changed field
|
||||
for (const mid of resolvedMetricIds) {
|
||||
const current = activeMetricMap.get(mid);
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'resolution_date', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'remediation_plan', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update only matching rows
|
||||
values.push(hostname);
|
||||
values.push(resolvedMetricIds);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND metric_id = ANY($${paramIdx + 1}) AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText, metric_ids: resolvedMetricIds },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} else {
|
||||
// --- Hostname-level path (backward compatible, NULL metric_id in history) ---
|
||||
// Get current values before updating (pick one representative row)
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
// Insert history for each changed field with NULL metric_id
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all active items for hostname
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
}
|
||||
|
||||
// Update the items
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||
|
||||
Reference in New Issue
Block a user