Files
cve-dashboard/.kiro/specs/remediation-plan-history/design.md
2026-05-19 15:01:25 -06:00

13 KiB

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

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:

{
  "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:

    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:

    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:

{
  "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:

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

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.