Files
cve-dashboard/.kiro/specs/remediation-plan-history/design.md

280 lines
13 KiB
Markdown
Raw Normal View History

# Design Document: Remediation Plan History
## Overview
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new `compliance_item_history` table for historical entries. The pattern mirrors how `compliance_notes` works — separate rows with timestamps and attribution.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ ComplianceDetailPanel │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
│ │ Date Input │ │ Plan Input │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────────┘ │
│ │ │
│ PATCH /metadata │
│ { resolution_date, remediation_plan, │
│ change_reason } │
│ │ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Change History Section │ │
│ │ [date] [field] [old→new] [by user] [reason] │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend (compliance.js) │
│ │
│ PATCH /items/:hostname/metadata │
│ 1. Validate inputs │
│ 2. SELECT current values from compliance_items │
│ 3. Compare old vs new — skip if identical │
│ 4. INSERT into compliance_item_history (per changed field) │
│ 5. UPDATE compliance_items with new values │
│ │
│ GET /items/:hostname │
│ (existing) + SELECT from compliance_item_history │
│ LIMIT 10 ORDER BY changed_at DESC │
│ │
│ POST /vcl/bulk-commit │
│ For each hostname: same compare-then-insert pattern │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ compliance_items (unchanged) │
│ resolution_date DATE │
│ remediation_plan TEXT │
│ │
│ compliance_item_history (new) │
│ id, hostname, field_name, old_value, new_value, │
│ change_reason, changed_by, changed_at │
└─────────────────────────────────────────────────────────────────┘
```
## Data Model
### New Table: compliance_item_history
```sql
CREATE TABLE IF NOT EXISTS compliance_item_history (
id SERIAL PRIMARY KEY,
hostname TEXT NOT NULL,
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
old_value TEXT,
new_value TEXT,
change_reason TEXT,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
ON compliance_item_history(hostname, field_name);
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
ON compliance_item_history(changed_at);
```
### Existing Table: compliance_items (no changes)
The `resolution_date` and `remediation_plan` columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
## API Changes
### PATCH /api/compliance/items/:hostname/metadata
**Request body changes:**
```json
{
"resolution_date": "2026-03-15",
"remediation_plan": "Upgrade firmware to v4.2",
"change_reason": "Vendor pushed back delivery date"
}
```
New optional field: `change_reason` (string, max 500 characters, nullable).
**Behavior changes:**
1. Before updating compliance_items, query the current values:
```sql
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
FROM compliance_items
WHERE hostname = $1 AND status = 'active'
ORDER BY hostname, id DESC
LIMIT 1
```
2. For each field being updated, compare old vs new. If different, insert a history row:
```sql
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
VALUES ($1, $2, $3, $4, $5, $6)
```
3. Then proceed with the existing UPDATE as before.
**Response:** unchanged (`{ updated: number }`).
### GET /api/compliance/items/:hostname
**Response changes:**
Add a `history` array to the response:
```json
{
"hostname": "server01.example.com",
"ip_address": "10.0.1.5",
"device_type": "Server",
"team": "STEAM",
"resolution_date": "2026-03-15",
"remediation_plan": "Upgrade firmware to v4.2",
"metrics": [...],
"notes": [...],
"history": [
{
"id": 42,
"field_name": "resolution_date",
"old_value": "2026-02-01",
"new_value": "2026-03-15",
"change_reason": "Vendor pushed back delivery date",
"changed_by": "jsmith",
"changed_at": "2026-01-20T14:30:00.000Z"
},
{
"id": 41,
"field_name": "remediation_plan",
"old_value": null,
"new_value": "Upgrade firmware to v4.2",
"change_reason": null,
"changed_by": "jsmith",
"changed_at": "2026-01-15T09:00:00.000Z"
}
]
}
```
Query:
```sql
SELECT 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
```
### POST /api/compliance/vcl/bulk-commit
**Behavior changes:**
Within the transaction, before updating each hostname:
1. Query current values for that hostname
2. Compare each field (resolution_date, remediation_plan) against the incoming value
3. Insert history rows for changed fields with `changed_by` set to `req.user.username`
No request/response shape changes. The `change_reason` is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
## Frontend Changes
### ComplianceDetailPanel.js
**New state:**
- `changeReason` (string) — text input value for the reason field
- `history` (array) — populated from the API response
**New UI elements:**
1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
2. **Change History section** — a new `<Section>` component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
**History entry display format:**
```
[field_name icon] old_value → new_value
username · 2026-01-20 [reason if present]
```
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
- Change reason: displayed in muted text below the change line when present
**Save flow update:**
- The `handleSaveMetadata` function passes `change_reason` alongside the field values
- After successful save, clear the `changeReason` input and re-fetch detail (which now includes updated history)
## Error Handling
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
- `change_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
## Migration
File: `backend/migrations/add_compliance_item_history.js`
```javascript
const pool = require('../db');
async function run() {
console.log('Starting compliance_item_history migration...');
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS compliance_item_history (
id SERIAL PRIMARY KEY,
hostname TEXT NOT NULL,
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
old_value TEXT,
new_value TEXT,
change_reason TEXT,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW()
)
`);
console.log('✓ compliance_item_history table created (or already exists)');
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
ON compliance_item_history(hostname, field_name)
`);
console.log('✓ hostname/field_name index created');
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
ON compliance_item_history(changed_at)
`);
console.log('✓ changed_at index created');
console.log('Migration complete.');
} catch (err) {
console.error('Migration failed:', err.message);
throw err;
}
}
module.exports = { run };
```
## Reporting Isolation
No changes to VCL reporting queries. The following queries continue to read directly from `compliance_items.resolution_date`:
- Donut chart: `SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname`
- Burndown forecast: `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL`
- Per-vertical burndown in vclMultiVertical.js
The `compliance_item_history` table is never referenced by any reporting query.
## Performance Considerations
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
- No additional queries are added to the VCL reporting paths.