764 lines
28 KiB
Markdown
764 lines
28 KiB
Markdown
# Design Document: PostgreSQL Migration
|
|
|
|
## Overview
|
|
|
|
Migrate the CVE Dashboard backend from SQLite (`cve_database.db`, 13MB) to PostgreSQL 16 running in a dedicated Docker container (`steam-postgres`) on port 5433. The primary architectural change is decomposing the monolithic `ivanti_findings_cache.findings_json` blob (2.6MB TEXT column) into individual rows in an `ivanti_findings` table. This eliminates JSON parsing on every request, enables indexed per-BU filtering, provides per-BU closed finding counts, and removes SQLite's single-writer lock that blocks reads during sync.
|
|
|
|
The Postgres instance is fully isolated from the existing Postgres on port 5432 (belonging to another project). The frontend requires zero changes — the API contract remains identical.
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TB
|
|
subgraph "Frontend (port 3000)"
|
|
FE[React SPA]
|
|
end
|
|
|
|
subgraph "Backend (port 3001 prod / 3003 dev)"
|
|
SERVER[Express Server]
|
|
POOL[pg Pool - max 10 connections]
|
|
ROUTES[Route Handlers - async/await]
|
|
SYNC[Ivanti Sync Logic]
|
|
end
|
|
|
|
subgraph "Docker: steam-postgres (port 5433)"
|
|
PG[(PostgreSQL 16 Alpine)]
|
|
DB[cve_dashboard database]
|
|
VOL[steam-pgdata volume]
|
|
end
|
|
|
|
subgraph "Existing Infrastructure (DO NOT TOUCH)"
|
|
OTHER_PG[(Other Postgres - port 5432)]
|
|
SQLITE[(SQLite backup - cve_database.db)]
|
|
end
|
|
|
|
FE -->|Same API contract| SERVER
|
|
SERVER --> ROUTES
|
|
ROUTES --> POOL
|
|
SYNC --> POOL
|
|
POOL -->|DATABASE_URL| PG
|
|
PG --> DB
|
|
DB --> VOL
|
|
```
|
|
|
|
### Key Architecture Decisions
|
|
|
|
| Decision | Rationale |
|
|
|----------|-----------|
|
|
| Dedicated Docker container on port 5433 | Isolation from existing Postgres on 5432; independent lifecycle |
|
|
| `pg` package with connection pool (max 10) | Concurrent reads during writes; no single-writer lock |
|
|
| Individual finding rows instead of JSON blob | Indexed queries, per-BU filtering in SQL, no JSON.parse |
|
|
| Closed findings stored with `bu_ownership` | Enables per-BU closed counts (currently only global count) |
|
|
| Batch upsert via `INSERT ... ON CONFLICT` | Idempotent sync; no data loss on re-runs |
|
|
| Blue-green cutover on same port | <30s downtime; instant rollback by reverting .env |
|
|
|
|
## Components and Interfaces
|
|
|
|
### 1. Connection Pool Module (`backend/db.js`)
|
|
|
|
New module that creates and exports a `pg` Pool instance. All route files import this instead of receiving a `db` (sqlite3) parameter.
|
|
|
|
```js
|
|
// backend/db.js
|
|
const { Pool } = require('pg');
|
|
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
// postgresql://steam:<password>@localhost:5433/cve_dashboard
|
|
max: 10,
|
|
idleTimeoutMillis: 30000,
|
|
connectionTimeoutMillis: 5000,
|
|
});
|
|
|
|
// Log pool errors (connection drops, etc.)
|
|
pool.on('error', (err) => {
|
|
console.error('[DB Pool] Unexpected error on idle client:', err.message);
|
|
});
|
|
|
|
// Warn when approaching pool exhaustion
|
|
let activeCount = 0;
|
|
pool.on('acquire', () => {
|
|
activeCount++;
|
|
if (activeCount >= 8) {
|
|
console.warn(`[DB Pool] WARNING: ${activeCount}/10 connections active — approaching exhaustion`);
|
|
}
|
|
});
|
|
pool.on('release', () => { activeCount--; });
|
|
|
|
module.exports = pool;
|
|
```
|
|
|
|
### 2. Route Migration Pattern
|
|
|
|
Every route file changes from callback-based SQLite to async/await Postgres:
|
|
|
|
**Before (SQLite):**
|
|
```js
|
|
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const users = await new Promise((resolve, reject) => {
|
|
db.all(
|
|
'SELECT id, username, email, user_group AS "group" FROM users ORDER BY created_at DESC',
|
|
(err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows);
|
|
}
|
|
);
|
|
});
|
|
res.json(users);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to fetch users' });
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
**After (Postgres):**
|
|
```js
|
|
const pool = require('../db');
|
|
|
|
function createUsersRouter(requireAuth, requireGroup, logAudit) {
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const { rows: users } = await pool.query(
|
|
'SELECT id, username, email, user_group AS "group" FROM users ORDER BY created_at DESC'
|
|
);
|
|
res.json(users);
|
|
} catch (err) {
|
|
console.error('Get users error:', err);
|
|
res.status(500).json({ error: 'Failed to fetch users' });
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
### 3. Query Pattern Translation
|
|
|
|
| SQLite Pattern | Postgres Equivalent |
|
|
|----------------|-------------------|
|
|
| `db.get(sql, [params], callback)` | `const { rows } = await pool.query(sql, [params]); const row = rows[0];` |
|
|
| `db.all(sql, [params], callback)` | `const { rows } = await pool.query(sql, [params]);` |
|
|
| `db.run(sql, [params], callback)` | `await pool.query(sql, [params]);` or with `RETURNING` |
|
|
| `?` placeholders | `$1, $2, $3...` numbered params |
|
|
| `INSERT OR IGNORE` | `INSERT ... ON CONFLICT DO NOTHING` |
|
|
| `datetime('now')` | `NOW()` |
|
|
| `LIKE` (case-sensitive) | `ILIKE` (case-insensitive) |
|
|
|
|
### 4. Ivanti Sync Component (Rewritten)
|
|
|
|
The sync logic changes from "serialize all findings to JSON blob" to "upsert individual rows":
|
|
|
|
```js
|
|
// backend/routes/ivantiFindings.js — sync logic (simplified)
|
|
async function syncFindings(pool) {
|
|
const allFindings = await fetchAllFromIvanti(); // paginated API calls
|
|
|
|
// Batch upsert in chunks of 100
|
|
for (let i = 0; i < allFindings.length; i += 100) {
|
|
const batch = allFindings.slice(i, i + 100);
|
|
const values = [];
|
|
const placeholders = batch.map((f, idx) => {
|
|
const offset = idx * 14;
|
|
values.push(f.id, f.hostId, f.title, f.severity, f.vrrGroup,
|
|
f.hostName, f.ipAddress, f.dns, f.status, f.slaStatus,
|
|
f.dueDate, f.lastFoundOn, f.buOwnership, f.cves || []);
|
|
return `($${offset+1},$${offset+2},$${offset+3},$${offset+4},$${offset+5},
|
|
$${offset+6},$${offset+7},$${offset+8},$${offset+9},$${offset+10},
|
|
$${offset+11},$${offset+12},$${offset+13},$${offset+14},'open')`;
|
|
});
|
|
|
|
await pool.query(`
|
|
INSERT INTO ivanti_findings (id, host_id, title, severity, vrr_group,
|
|
host_name, ip_address, dns, status, sla_status,
|
|
due_date, last_found_on, bu_ownership, cves, state)
|
|
VALUES ${placeholders.join(',')}
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
severity = EXCLUDED.severity,
|
|
host_name = EXCLUDED.host_name,
|
|
ip_address = EXCLUDED.ip_address,
|
|
dns = EXCLUDED.dns,
|
|
status = EXCLUDED.status,
|
|
sla_status = EXCLUDED.sla_status,
|
|
due_date = EXCLUDED.due_date,
|
|
last_found_on = EXCLUDED.last_found_on,
|
|
bu_ownership = EXCLUDED.bu_ownership,
|
|
cves = EXCLUDED.cves,
|
|
state = EXCLUDED.state,
|
|
synced_at = NOW()
|
|
`, values);
|
|
}
|
|
|
|
// Update sync metadata
|
|
await pool.query(`
|
|
UPDATE ivanti_sync_state SET
|
|
total = (SELECT COUNT(*) FROM ivanti_findings WHERE state = 'open'),
|
|
synced_at = NOW(),
|
|
sync_status = 'success',
|
|
error_message = NULL
|
|
WHERE id = 1
|
|
`);
|
|
}
|
|
```
|
|
|
|
### 5. Auth Middleware Migration
|
|
|
|
```js
|
|
// backend/middleware/auth.js — After
|
|
const pool = require('../db');
|
|
|
|
function requireAuth() {
|
|
return async (req, res, next) => {
|
|
const sessionId = req.cookies?.session_id;
|
|
if (!sessionId) return res.status(401).json({ error: 'Authentication required' });
|
|
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`SELECT s.*, u.id as user_id, u.username, u.email, u.role,
|
|
u.user_group, u.bu_teams, u.is_active
|
|
FROM sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
|
[sessionId]
|
|
);
|
|
const session = rows[0];
|
|
if (!session) return res.status(401).json({ error: 'Session expired or invalid' });
|
|
if (!session.is_active) return res.status(401).json({ error: 'Account is disabled' });
|
|
|
|
req.user = {
|
|
id: session.user_id,
|
|
username: session.username,
|
|
email: session.email,
|
|
role: session.role,
|
|
group: session.user_group,
|
|
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
|
};
|
|
next();
|
|
} catch (err) {
|
|
console.error('Auth middleware error:', err);
|
|
return res.status(500).json({ error: 'Authentication error' });
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
## Data Models
|
|
|
|
### Complete DDL for `ivanti_findings` Table
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS ivanti_findings (
|
|
id TEXT PRIMARY KEY, -- Ivanti finding ID (e.g. "HF-12345")
|
|
host_id INTEGER,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
severity NUMERIC(4,2) NOT NULL DEFAULT 0,
|
|
vrr_group TEXT NOT NULL DEFAULT '',
|
|
host_name TEXT NOT NULL DEFAULT '',
|
|
ip_address TEXT NOT NULL DEFAULT '',
|
|
dns TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT '',
|
|
sla_status TEXT NOT NULL DEFAULT '',
|
|
due_date DATE,
|
|
last_found_on DATE,
|
|
bu_ownership TEXT NOT NULL DEFAULT '',
|
|
cves TEXT[] DEFAULT '{}', -- Postgres array type
|
|
workflow_id TEXT,
|
|
workflow_state TEXT,
|
|
workflow_type TEXT,
|
|
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
|
|
note TEXT NOT NULL DEFAULT '', -- Merged from ivanti_finding_notes
|
|
override_host_name TEXT, -- Merged from ivanti_finding_overrides
|
|
override_dns TEXT, -- Merged from ivanti_finding_overrides
|
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Performance indexes
|
|
CREATE INDEX IF NOT EXISTS idx_findings_state ON ivanti_findings(state);
|
|
CREATE INDEX IF NOT EXISTS idx_findings_bu ON ivanti_findings(bu_ownership);
|
|
CREATE INDEX IF NOT EXISTS idx_findings_severity ON ivanti_findings(severity);
|
|
CREATE INDEX IF NOT EXISTS idx_findings_state_bu ON ivanti_findings(state, bu_ownership);
|
|
```
|
|
|
|
### Core Tables (Postgres DDL)
|
|
|
|
```sql
|
|
-- Users
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(50) UNIQUE NOT NULL,
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')),
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
last_login TIMESTAMPTZ,
|
|
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only',
|
|
bu_teams TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
-- Sessions
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id SERIAL PRIMARY KEY,
|
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
|
|
|
-- Audit Logs
|
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER,
|
|
username VARCHAR(50) NOT NULL,
|
|
action VARCHAR(50) NOT NULL,
|
|
entity_type VARCHAR(50) NOT NULL,
|
|
entity_id VARCHAR(100),
|
|
details TEXT,
|
|
ip_address VARCHAR(45),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
|
|
|
-- CVEs
|
|
CREATE TABLE IF NOT EXISTS cves (
|
|
id SERIAL PRIMARY KEY,
|
|
cve_id VARCHAR(20) NOT NULL,
|
|
vendor VARCHAR(100) NOT NULL,
|
|
severity VARCHAR(20) NOT NULL,
|
|
description TEXT,
|
|
published_date DATE,
|
|
status VARCHAR(50) DEFAULT 'Open',
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by INTEGER,
|
|
UNIQUE(cve_id, vendor)
|
|
);
|
|
|
|
-- Jira Tickets
|
|
CREATE TABLE IF NOT EXISTS jira_tickets (
|
|
id SERIAL PRIMARY KEY,
|
|
cve_id TEXT NOT NULL,
|
|
vendor TEXT NOT NULL,
|
|
ticket_key TEXT NOT NULL,
|
|
url TEXT,
|
|
summary TEXT,
|
|
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Sync State (replaces ivanti_findings_cache metadata)
|
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
|
total INTEGER DEFAULT 0,
|
|
workflows_json TEXT DEFAULT '[]',
|
|
synced_at TIMESTAMPTZ,
|
|
sync_status TEXT DEFAULT 'never',
|
|
error_message TEXT
|
|
);
|
|
|
|
-- Ivanti Counts Cache (for FP workflow counts)
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
|
open_count INTEGER DEFAULT 0,
|
|
closed_count INTEGER DEFAULT 0,
|
|
synced_at TIMESTAMPTZ,
|
|
fp_workflow_counts_json TEXT DEFAULT '{}',
|
|
fp_id_counts_json TEXT DEFAULT '{}'
|
|
);
|
|
|
|
-- Ivanti Counts History
|
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
|
id SERIAL PRIMARY KEY,
|
|
open_count INTEGER NOT NULL,
|
|
closed_count INTEGER NOT NULL,
|
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Finding Archives
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
|
id SERIAL PRIMARY KEY,
|
|
finding_id TEXT NOT NULL UNIQUE,
|
|
finding_title TEXT NOT NULL DEFAULT '',
|
|
host_name TEXT NOT NULL DEFAULT '',
|
|
ip_address TEXT NOT NULL DEFAULT '',
|
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
|
last_severity NUMERIC(4,2) NOT NULL DEFAULT 0,
|
|
first_archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Archive Transitions
|
|
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
|
id SERIAL PRIMARY KEY,
|
|
archive_id INTEGER NOT NULL REFERENCES ivanti_finding_archives(id),
|
|
from_state TEXT NOT NULL,
|
|
to_state TEXT NOT NULL,
|
|
severity_at_transition NUMERIC(4,2) NOT NULL DEFAULT 0,
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Sync Anomaly Log
|
|
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
|
id SERIAL PRIMARY KEY,
|
|
sync_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
open_count_delta INTEGER NOT NULL DEFAULT 0,
|
|
closed_count_delta INTEGER NOT NULL DEFAULT 0,
|
|
newly_archived_count INTEGER NOT NULL DEFAULT 0,
|
|
returned_count INTEGER NOT NULL DEFAULT 0,
|
|
classification_json TEXT NOT NULL DEFAULT '{}',
|
|
return_classification_json TEXT NOT NULL DEFAULT '{}',
|
|
is_significant BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Finding BU History
|
|
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
|
id SERIAL PRIMARY KEY,
|
|
finding_id TEXT NOT NULL,
|
|
finding_title TEXT NOT NULL DEFAULT '',
|
|
host_name TEXT NOT NULL DEFAULT '',
|
|
previous_bu TEXT NOT NULL,
|
|
new_bu TEXT NOT NULL,
|
|
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti FP Submissions
|
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT NOT NULL,
|
|
ivanti_workflow_batch_id INTEGER,
|
|
ivanti_generated_id TEXT,
|
|
ivanti_workflow_batch_uuid TEXT,
|
|
workflow_name TEXT NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
description TEXT,
|
|
expiration_date TEXT NOT NULL,
|
|
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
|
finding_ids_json TEXT NOT NULL,
|
|
queue_item_ids_json TEXT NOT NULL,
|
|
attachment_count INTEGER DEFAULT 0,
|
|
attachment_results_json TEXT,
|
|
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
|
|
lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted')),
|
|
error_message TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NULL
|
|
);
|
|
|
|
-- Ivanti FP Submission History
|
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
|
id SERIAL PRIMARY KEY,
|
|
submission_id INTEGER NOT NULL REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT NOT NULL,
|
|
change_type TEXT NOT NULL CHECK(change_type IN ('created', 'fields_updated', 'findings_added', 'attachments_added', 'status_changed')),
|
|
change_details_json TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Ivanti Todo Queue
|
|
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
finding_id TEXT NOT NULL,
|
|
finding_title TEXT,
|
|
cves_json TEXT,
|
|
ip_address TEXT,
|
|
hostname TEXT,
|
|
vendor TEXT NOT NULL,
|
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status);
|
|
|
|
-- Atlas Action Plans Cache
|
|
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
|
id SERIAL PRIMARY KEY,
|
|
host_id INTEGER NOT NULL UNIQUE,
|
|
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
|
plan_count INTEGER NOT NULL DEFAULT 0,
|
|
plans_json TEXT NOT NULL DEFAULT '[]',
|
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Compliance Tables
|
|
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
|
id SERIAL PRIMARY KEY,
|
|
filename TEXT NOT NULL,
|
|
report_date TEXT,
|
|
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
|
|
new_count INTEGER DEFAULT 0,
|
|
resolved_count INTEGER DEFAULT 0,
|
|
recurring_count INTEGER DEFAULT 0,
|
|
summary_json TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS compliance_items (
|
|
id SERIAL PRIMARY KEY,
|
|
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
|
hostname TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
device_type TEXT,
|
|
team TEXT,
|
|
metric_id TEXT NOT NULL,
|
|
metric_desc TEXT,
|
|
category TEXT,
|
|
extra_json TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
|
first_seen_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
|
resolved_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
|
seen_count INTEGER DEFAULT 1,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS compliance_notes (
|
|
id SERIAL PRIMARY KEY,
|
|
hostname TEXT NOT NULL,
|
|
metric_id TEXT NOT NULL,
|
|
note TEXT NOT NULL,
|
|
group_id TEXT,
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Knowledge Base
|
|
CREATE TABLE IF NOT EXISTS knowledge_base (
|
|
id SERIAL PRIMARY KEY,
|
|
title VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
|
description TEXT,
|
|
category VARCHAR(100),
|
|
file_path VARCHAR(500),
|
|
file_name VARCHAR(255),
|
|
file_type VARCHAR(50),
|
|
file_size INTEGER,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by INTEGER REFERENCES users(id)
|
|
);
|
|
|
|
-- Archer Tickets
|
|
CREATE TABLE IF NOT EXISTS archer_tickets (
|
|
id SERIAL PRIMARY KEY,
|
|
exc_number TEXT NOT NULL UNIQUE,
|
|
archer_url TEXT,
|
|
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
|
cve_id TEXT NOT NULL,
|
|
vendor TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Documents
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
id SERIAL PRIMARY KEY,
|
|
cve_id VARCHAR(20) NOT NULL,
|
|
vendor VARCHAR(100) NOT NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
type VARCHAR(50) NOT NULL,
|
|
file_path VARCHAR(500) NOT NULL,
|
|
file_size VARCHAR(20),
|
|
mime_type VARCHAR(100),
|
|
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
|
|
notes TEXT
|
|
);
|
|
|
|
-- Required Documents (seed data)
|
|
CREATE TABLE IF NOT EXISTS required_documents (
|
|
id SERIAL PRIMARY KEY,
|
|
vendor VARCHAR(100) NOT NULL,
|
|
document_type VARCHAR(50) NOT NULL,
|
|
is_mandatory BOOLEAN DEFAULT TRUE,
|
|
description TEXT
|
|
);
|
|
```
|
|
|
|
### Per-BU Count Queries
|
|
|
|
```sql
|
|
-- Open count for specific BUs (used by counts endpoint with teams filter)
|
|
SELECT COUNT(*) FROM ivanti_findings
|
|
WHERE state = 'open' AND bu_ownership ILIKE ANY(ARRAY['%STEAM%', '%ACCESS-ENG%']);
|
|
|
|
-- Closed count for specific BUs
|
|
SELECT COUNT(*) FROM ivanti_findings
|
|
WHERE state = 'closed' AND bu_ownership ILIKE ANY(ARRAY['%STEAM%', '%ACCESS-ENG%']);
|
|
|
|
-- Aggregated counts grouped by BU and state (single query)
|
|
SELECT bu_ownership, state, COUNT(*) as count
|
|
FROM ivanti_findings
|
|
GROUP BY bu_ownership, state;
|
|
|
|
-- Global totals (no filter — backward compatible)
|
|
SELECT state, COUNT(*) as count
|
|
FROM ivanti_findings
|
|
GROUP BY state;
|
|
```
|
|
|
|
### Data Migration Script Design (`backend/scripts/migrate-to-postgres.js`)
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[Open SQLite read-only] --> B[Connect to Postgres pool]
|
|
B --> C[Create all tables IF NOT EXISTS]
|
|
C --> D[Copy simple tables]
|
|
D --> E[Parse findings_json blob]
|
|
E --> F[Insert individual finding rows state=open]
|
|
F --> G[Merge ivanti_finding_notes → findings.note]
|
|
G --> H[Merge ivanti_finding_overrides → findings.override_*]
|
|
H --> I[Verify row counts]
|
|
I --> J[Print summary report]
|
|
```
|
|
|
|
The migration script:
|
|
1. Opens SQLite with `OPEN_READONLY` flag
|
|
2. Connects to Postgres via `DATABASE_URL`
|
|
3. Creates schema idempotently (`IF NOT EXISTS`)
|
|
4. Copies each table using batch inserts with `ON CONFLICT` for idempotency
|
|
5. Special handling for findings: parses `findings_json`, creates one row per finding
|
|
6. Merges notes and overrides into the corresponding finding rows
|
|
7. Verifies source vs destination row counts
|
|
8. Never modifies the SQLite file
|
|
|
|
## Correctness Properties
|
|
|
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
|
|
|
### Property 1: Upsert Idempotence
|
|
|
|
*For any* finding synced N times (N ≥ 1) with the same finding ID, the `ivanti_findings` table SHALL contain exactly one row for that finding ID, with the data from the most recent sync.
|
|
|
|
**Validates: Requirements 3.5, 6.6**
|
|
|
|
### Property 2: Finding Storage Preserves State and BU Ownership
|
|
|
|
*For any* finding (open or closed) stored in `ivanti_findings`, querying it back by ID SHALL return the same `state` and `bu_ownership` values it was stored with.
|
|
|
|
**Validates: Requirements 3.4, 4.1**
|
|
|
|
### Property 3: Count Query Accuracy
|
|
|
|
*For any* set of findings in `ivanti_findings` and any BU filter (including empty/no filter), the count query result SHALL equal the actual number of rows matching that filter and state combination.
|
|
|
|
**Validates: Requirements 4.2, 4.3, 4.5**
|
|
|
|
### Property 4: Migration Data Preservation (Findings)
|
|
|
|
*For any* finding in the source `findings_json` blob with associated notes (from `ivanti_finding_notes`) and overrides (from `ivanti_finding_overrides`), the migrated `ivanti_findings` row SHALL contain the finding data with `state = 'open'`, the correct `note` value, and the correct `override_host_name`/`override_dns` values.
|
|
|
|
**Validates: Requirements 7.4, 7.5, 7.6**
|
|
|
|
### Property 5: Migration Table Copy Preservation
|
|
|
|
*For any* table copied from SQLite to Postgres, the row count in Postgres SHALL equal the row count in SQLite, and each row's data SHALL be equivalent (accounting for type conversions: 0/1 → boolean, DATETIME → TIMESTAMPTZ).
|
|
|
|
**Validates: Requirements 7.7, 7.8**
|
|
|
|
### Property 6: Migration Idempotence
|
|
|
|
*For any* initial state of the SQLite and Postgres databases, running the migration script N times (N ≥ 1) SHALL produce the same final state in Postgres as running it exactly once (no duplicate rows, no errors).
|
|
|
|
**Validates: Requirements 7.3, 7.9**
|
|
|
|
### Property 7: Migration Source Safety
|
|
|
|
*For any* execution of the migration script, the SQLite database file SHALL remain byte-for-byte identical before and after (checksum unchanged).
|
|
|
|
**Validates: Requirements 7.10**
|
|
|
|
### Property 8: Schema Creation Idempotence
|
|
|
|
*For any* number of times the schema creation DDL is executed against the same database, the resulting schema SHALL be identical (no errors, same tables, same indexes, same constraints).
|
|
|
|
**Validates: Requirements 2.5**
|
|
|
|
### Property 9: API Response Shape Preservation
|
|
|
|
*For any* valid API request to any endpoint, the response JSON structure (top-level keys and value types) after migration SHALL be identical to the pre-migration response structure.
|
|
|
|
**Validates: Requirements 10.1**
|
|
|
|
## Error Handling
|
|
|
|
| Error Scenario | Handling Strategy |
|
|
|----------------|-------------------|
|
|
| Pool connection failure | `pool.on('error')` logs error; automatic reconnection on next query attempt |
|
|
| Pool exhaustion (all 10 busy) | Queries queue internally; warning logged at 8 active connections |
|
|
| Query timeout | `connectionTimeoutMillis: 5000` — rejects after 5s with error |
|
|
| Sync failure mid-batch | Transaction rollback; `sync_status = 'error'` with message; previous data preserved |
|
|
| Migration script failure | Idempotent design — safe to re-run; prints error and exits with code 1 |
|
|
| Docker container crash | `--restart unless-stopped` auto-recovers; pool reconnects on next query |
|
|
| Invalid finding data | `NOT NULL DEFAULT ''` columns prevent null constraint violations; CHECK constraints reject invalid state values |
|
|
| Rollback needed | Stop Postgres backend → revert `.env` → restart SQLite backend; SQLite file always preserved |
|
|
|
|
### Error Response Format (Unchanged)
|
|
|
|
All error responses maintain the existing format:
|
|
```json
|
|
{ "error": "Human-readable error message" }
|
|
```
|
|
|
|
With appropriate HTTP status codes: 400 (validation), 401 (auth), 403 (permission), 404 (not found), 500 (server error).
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests (Example-Based)
|
|
|
|
- Verify each route returns correct response shape with known test data
|
|
- Verify auth middleware rejects expired sessions
|
|
- Verify parameter placeholder conversion (`?` → `$1`) in all queries
|
|
- Verify schema DDL executes without errors
|
|
- Verify migration script handles empty tables gracefully
|
|
|
|
### Property-Based Tests
|
|
|
|
Property-based testing is appropriate for this feature because the core operations (upsert, count queries, data migration) have clear input/output behavior with universal properties that hold across a wide input space.
|
|
|
|
**Library**: [fast-check](https://github.com/dubzzz/fast-check) (JavaScript PBT library)
|
|
|
|
**Configuration**: Minimum 100 iterations per property test.
|
|
|
|
**Tag format**: `Feature: postgres-migration, Property {number}: {property_text}`
|
|
|
|
Each correctness property (1-9) maps to a single property-based test:
|
|
- Property 1: Generate random findings, upsert each N times, verify exactly one row per ID
|
|
- Property 2: Generate findings with random state/bu_ownership, store and retrieve, verify equality
|
|
- Property 3: Generate random finding sets, insert, run count queries with random filters, verify accuracy
|
|
- Property 4: Generate random findings JSON with notes/overrides, run migration logic, verify merged output
|
|
- Property 5: Generate random table rows, copy via migration, verify count and data equivalence
|
|
- Property 6: Run migration logic N times on same input, verify final state equals single-run state
|
|
- Property 7: Checksum SQLite before/after migration, verify unchanged
|
|
- Property 8: Run schema DDL N times, verify no errors and same schema
|
|
- Property 9: Compare response shapes between SQLite and Postgres backends for same requests
|
|
|
|
### Integration Tests
|
|
|
|
- Docker container health check (port 5433 accessible)
|
|
- Full sync cycle: trigger sync → verify rows created → verify counts endpoint
|
|
- Concurrent read during write: start sync, simultaneously query findings, verify no blocking
|
|
- Performance: GET /findings < 500ms, GET /counts < 100ms with 6000+ rows
|
|
- Cutover simulation: stop/start backend, verify API responds correctly
|
|
|
|
### Development Isolation
|
|
|
|
- Test backend runs on port 3003 with `DATABASE_URL` pointing to Postgres
|
|
- Production backend continues on port 3001 with SQLite (no `DATABASE_URL` set)
|
|
- Switching is controlled by presence of `DATABASE_URL` environment variable
|
|
- All work on `feature/multi-tenancy` branch
|