-- ============================================================================= -- CVE Dashboard — Complete PostgreSQL Schema (v1.0.0) -- ============================================================================= -- Translates the full SQLite schema (setup.js) to PostgreSQL 16. -- Designed for idempotent execution: safe to run multiple times via psql or -- pool.query() without errors or duplicate data. -- -- Usage: -- psql -h localhost -p 5433 -U steam -d cve_dashboard -f backend/db-schema.sql -- OR -- const schema = fs.readFileSync('backend/db-schema.sql', 'utf8'); -- await pool.query(schema); -- ============================================================================= -- ============================================================================= -- Core CVE tracking tables -- ============================================================================= 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) ); CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id); CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor); CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity); CREATE INDEX IF NOT EXISTS idx_status ON cves(status); 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 ); CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id); CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor); CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type); 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, UNIQUE(vendor, document_type) ); -- ============================================================================= -- Authentication and session management -- ============================================================================= 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' CHECK (user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')), bu_teams TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group); 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 logging -- ============================================================================= 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_user_id ON audit_logs(user_id); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action); CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type); CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at); -- ============================================================================= -- Jira integration -- ============================================================================= 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', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor); CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status); -- ============================================================================= -- Archer integration -- ============================================================================= 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_by INTEGER REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor); CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status); CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number); -- ============================================================================= -- 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) ); CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug ON knowledge_base(slug); CREATE INDEX IF NOT EXISTS idx_knowledge_base_category ON knowledge_base(category); CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at ON knowledge_base(created_at DESC); -- ============================================================================= -- Ivanti findings — individual rows (replaces findings_json blob) -- ============================================================================= CREATE TABLE IF NOT EXISTS ivanti_findings ( id TEXT PRIMARY KEY, 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 '{}', 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 '', override_host_name TEXT, override_dns TEXT, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); 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); -- ============================================================================= -- Ivanti sync state (single-row pattern — 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 (single-row pattern 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() ); CREATE TABLE IF NOT EXISTS ivanti_counts_history_by_bu ( id SERIAL PRIMARY KEY, bu_ownership TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('open', 'closed')), count INTEGER NOT NULL DEFAULT 0, recorded_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_counts_history_bu ON ivanti_counts_history_by_bu(bu_ownership); CREATE INDEX IF NOT EXISTS idx_counts_history_bu_date ON ivanti_counts_history_by_bu(recorded_at); -- ============================================================================= -- Ivanti FP (False Positive) 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 ); CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id); CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id); 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() ); CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id); -- ============================================================================= -- Ivanti todo queue (FP, Archer, CARD, GRANITE workflows) -- ============================================================================= 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', 'DECOM')), 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); -- ============================================================================= -- Ivanti archive detection and anomaly tracking -- ============================================================================= 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() ); CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id); CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state); 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() ); CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id); 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() ); CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp); 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() ); CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id); CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at); -- ============================================================================= -- 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() ); CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id ON atlas_action_plans_cache(host_id); -- ============================================================================= -- Compliance (NTS AEO) tracking -- ============================================================================= 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 INDEX IF NOT EXISTS idx_compliance_items_upload ON compliance_items(upload_id); CREATE INDEX IF NOT EXISTS idx_compliance_items_identity ON compliance_items(hostname, metric_id); CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status ON compliance_items(team, status); 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() ); CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity ON compliance_notes(hostname, metric_id); CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id); -- ============================================================================= -- Seed data -- ============================================================================= -- Required documents (idempotent via unique constraint on vendor + document_type) INSERT INTO required_documents (vendor, document_type, is_mandatory, description) VALUES ('Microsoft', 'advisory', TRUE, 'Official Microsoft Security Advisory'), ('Microsoft', 'screenshot', FALSE, 'Proof of patch application'), ('Cisco', 'advisory', TRUE, 'Cisco Security Advisory'), ('Oracle', 'advisory', TRUE, 'Oracle Security Alert'), ('VMware', 'advisory', TRUE, 'VMware Security Advisory'), ('Adobe', 'advisory', TRUE, 'Adobe Security Bulletin') ON CONFLICT (vendor, document_type) DO NOTHING; -- Ivanti sync state — ensure single row exists INSERT INTO ivanti_sync_state (id, total, workflows_json, sync_status) VALUES (1, 0, '[]', 'never') ON CONFLICT (id) DO NOTHING; -- Ivanti counts cache — ensure single row exists INSERT INTO ivanti_counts_cache (id, open_count, closed_count, fp_workflow_counts_json, fp_id_counts_json) VALUES (1, 0, 0, '{}', '{}') ON CONFLICT (id) DO NOTHING;