12 Commits

Author SHA1 Message Date
Jordan Ramos
6163be626e ops: add docker-compose.yml and deploy-postgres.sh for production cutover
- docker-compose.yml: Postgres 16 Alpine on port 5433 with healthcheck
- scripts/deploy-postgres.sh: one-shot deployment script that handles
  container startup, schema creation, npm install, data migration, and
  frontend build
- Backup SQLite database as cve_database.db.pre-postgres-backup
2026-05-06 15:07:06 -06:00
Jordan Ramos
573903a885 feat: per-BU trend lines in counts history chart
- Create ivanti_counts_history_by_bu table (bu_ownership, state, count per sync)
- Sync writes per-BU snapshot alongside global history on each sync
- Seed table with current counts for immediate first data point
- GET /counts/history accepts ?teams param — queries per-BU table when filtered
- IvantiCountsChart accepts teamsParam prop, re-fetches on scope change
- ReportingPage passes getActiveTeamsParam() to the chart
- Historical per-BU data accumulates from this point forward
- Global history (no filter) still uses the original aggregate table
2026-05-06 13:38:38 -06:00
Jordan Ramos
77f113e9ae fix: load dotenv in db.js so DATABASE_URL is available on import 2026-05-06 12:30:45 -06:00
Jordan Ramos
8cd73c126e feat(postgres): data migration + per-BU closed counts in frontend
- Create backend/scripts/migrate-to-postgres.js (one-time SQLite→Postgres copy)
- Successfully migrated: 6 users, 21 CVEs, 6307 findings, 20965 compliance items,
  138 archives, 67 atlas plans, all notes/overrides merged
- All 22 tables verified with matching row counts
- Frontend StatusDonut now uses server-provided per-BU counts (no more N/A)
- Counts endpoint called with teams param on scope change
- Re-fetch counts when admin scope toggle changes
2026-05-06 12:26:54 -06:00
Jordan Ramos
e30ad79f2a feat(postgres): rewrite Ivanti findings to individual rows
- Replace 2.6MB JSON blob with individual rows in ivanti_findings table
- Batch upsert via INSERT ... ON CONFLICT in chunks of 100
- Sync stores both open AND closed findings as rows with state column
- Per-BU closed counts now possible via SQL GROUP BY
- GET /findings queries indexed table with optional ILIKE BU filter
- GET /counts returns per-BU open+closed via GROUP BY state
- Notes and overrides are columns on ivanti_findings (no separate tables)
- Removed: readState, readStateWithNotes, _findingsCache, initTables
- Preserved: extractFinding, archive detection, FP workflow counts, anomaly log
- Response shape unchanged — frontend works without modification
2026-05-06 12:12:34 -06:00
Jordan Ramos
33927b150b feat(postgres): migrate all route files from SQLite to pg pool
- All 16 route files now import pool from ../db directly
- Removed db parameter from all factory functions
- All callbacks replaced with async/await pool.query()
- All ? placeholders converted to $1, $2... numbered params
- datetime('now') → NOW(), INSERT OR IGNORE → ON CONFLICT DO NOTHING
- LIKE → ILIKE for case-insensitive searches
- Error detection: err.code === '23505' for unique violations
- server.js no longer passes pool/db/requireAuth to route factories
- Only ivantiFindings.js still receives pool (pending task 8 rewrite)
2026-05-06 11:44:17 -06:00
Jordan Ramos
845d843e71 feat(postgres): infrastructure setup and schema creation (tasks 1-2)
- Install pg (node-postgres) dependency
- Create backend/db.js connection pool module (max 10, auto-reconnect)
- Install Docker and spin up steam-postgres container on port 5433
- Create backend/db-schema.sql with complete Postgres DDL (24 tables)
- Replace findings_json blob with ivanti_findings table (individual rows)
- Merge notes/overrides into findings table columns
- Add proper indexes: state, bu_ownership, severity, composite
- Create backend/setup-postgres.js for idempotent schema initialization
- Add DATABASE_URL to .env and .env.example
- Update migration plan docs with Docker setup commands
- Verify: schema executes cleanly, pool connects, 24 tables created
2026-05-05 15:47:09 -06:00
Jordan Ramos
5cdca09f40 docs: add Postgres migration plan and Kiro spec
- docs/guides/postgres-migration-plan.md: full migration manual with
  phases, port allocation, rollback plan, and timeline
- .kiro/specs/postgres-migration/: requirements, design, and tasks
- Replaces findings_json blob with individual indexed rows
- Enables per-BU closed counts via SQL queries
- Uses existing Postgres instance (port 5432), new cve_dashboard DB
- Testing on port 3003, cutover to 3001 with 30s downtime
2026-05-05 15:04:14 -06:00
Jordan Ramos
bd5fcccacf perf: client-side BU filtering for instant scope switching
- Fetch ALL findings once on mount (no teams param to backend)
- Filter client-side via scopedFindings useMemo keyed on adminScope
- Eliminates 5-10s round-trip on every scope change
- Open vs Closed donut now uses scopedFindings.length for open count
- Closed count remains global (no per-BU closed data available)
- Action Coverage donut automatically scoped via visibleFindings chain
- Remove server-side teams param from counts fetch (client handles it)
2026-05-05 12:08:01 -06:00
Jordan Ramos
df3173a720 feat: replace binary scope toggle with multi-select BU picker
- Add IVANTI_BU_FILTER to .env with all four BUs (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
- Rework AdminScopeToggle from binary (My Teams/All) to multi-select dropdown
- Admin can now pick any combination of BUs to view
- Presets: 'All BUs' and 'My Teams' for quick selection
- Individual team checkboxes for custom combinations
- Selection persisted in localStorage as JSON array
- AuthContext updated: adminScope is now an array of selected teams
- getActiveTeamsParam() returns comma-joined selected teams (empty = no filter)
- getAvailableTeams() returns selected teams for compliance selector
2026-05-05 11:31:15 -06:00
Jordan Ramos
9b8ae6cd79 fix: move AdminScopeToggle from NavDrawer to main header bar
Places the scope toggle next to the UserMenu avatar in the top-right
header area so it's always visible without opening the nav drawer.
2026-05-05 11:21:59 -06:00
Jordan Ramos
2656df94d3 feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema)
- Create shared KNOWN_TEAMS constant and validateTeams helper
- Expose user teams in auth middleware, login, and /me responses
- Add bu_teams CRUD to user management routes with audit logging
- Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var
- Add query-time team filtering to GET /findings and /findings/counts
- Update AuthContext with teams helpers and admin scope toggle
- Create AdminScopeToggle component (My Teams / All BUs)
- Scope ReportingPage findings fetch by user teams
- Scope CompliancePage team selector by user teams
- Scope ExportsPage findings exports by user teams
- Add BU teams multi-select to UserManagement create/edit forms
- Display team badges in user list table
2026-05-05 11:04:53 -06:00
45 changed files with 6097 additions and 5371 deletions

View File

@@ -17,6 +17,11 @@ IVANTI_API_KEY=
IVANTI_CLIENT_ID=1550 IVANTI_CLIENT_ID=1550
IVANTI_FIRST_NAME= IVANTI_FIRST_NAME=
IVANTI_LAST_NAME= IVANTI_LAST_NAME=
# Comma-separated list of BU values to sync from Ivanti.
# Broadening this pulls findings for additional BUs into the local cache.
# Users see only their assigned teams' findings (filtered at query time).
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False) # Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
IVANTI_SKIP_TLS=false IVANTI_SKIP_TLS=false
@@ -54,3 +59,14 @@ CARD_API_USER=
CARD_API_PASS= CARD_API_PASS=
# Set to true if behind Charter's SSL inspection proxy # Set to true if behind Charter's SSL inspection proxy
CARD_SKIP_TLS=false CARD_SKIP_TLS=false
# PostgreSQL Database (Docker container steam-postgres)
# If set, the backend uses Postgres instead of SQLite.
# Format: postgresql://user:password@host:port/database
DATABASE_URL=postgresql://steam:<password>@localhost:5433/cve_dashboard
# GitLab Feedback Integration (bug reports and feature requests from the dashboard)
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
GITLAB_URL=http://steam-gitlab.charterlab.com
GITLAB_PROJECT_ID=
GITLAB_PAT=

View File

@@ -0,0 +1,108 @@
/**
* Property-Based Test: JQL Window Invariant
*
* Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync
*
* For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(),
* the generated JQL string SHALL contain the substring `updated >= -72h` and
* SHALL contain the substring `project =`.
*
* Validates: Requirements 2.1, 2.3
*/
const fc = require('fast-check');
// Capture the JQL that flows through the HTTP layer.
let capturedJql = null;
// Mock https to intercept the request URL (which contains the JQL) and return
// a fake 200 response. This prevents real network calls while letting the
// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute.
jest.mock('https', () => ({
request: jest.fn((options, callback) => {
const fullPath = options.path || '';
const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/);
if (jqlMatch) {
capturedJql = decodeURIComponent(jqlMatch[1]);
}
const mockResponse = {
statusCode: 200,
on: jest.fn((event, handler) => {
if (event === 'data') {
handler(JSON.stringify({ total: 0, issues: [] }));
}
if (event === 'end') {
handler();
}
}),
};
// Use setImmediate so the callback fires on the same tick after promises
// resolve, but still asynchronously as Node's http expects.
setImmediate(() => callback(mockResponse));
return {
on: jest.fn(),
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
}),
}));
// Set required env vars before requiring the module so the module-level
// constants pick them up.
process.env.JIRA_PROJECT_KEY = 'TESTPROJ';
process.env.JIRA_BASE_URL = 'https://jira.example.com';
process.env.JIRA_API_USER = 'testuser';
process.env.JIRA_API_TOKEN = 'testtoken';
const jiraApi = require('../helpers/jiraApi');
describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => {
// Use fake timers so the rate-limiter's inter-request delays (12 seconds)
// resolve instantly. We preserve setImmediate so the https mock callback
// still fires asynchronously as expected.
beforeAll(() => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
capturedJql = null;
});
// Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc.
const issueKeyArb = fc.tuple(
fc.stringMatching(/^[A-Z]{2,10}$/),
fc.integer({ min: 1, max: 99999 })
).map(([prefix, num]) => `${prefix}-${num}`);
// Generator: non-empty array of issue keys (1 to 50 keys)
const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 });
it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => {
await fc.assert(
fc.asyncProperty(issueKeysArb, async (issueKeys) => {
capturedJql = null;
// Start the call — it will hit waitForDelay which uses setTimeout
const promise = jiraApi.searchIssuesByKeys(issueKeys);
// Advance fake timers to resolve any pending setTimeout from the
// rate limiter's waitForDelay function.
jest.advanceTimersByTime(5000);
await promise;
expect(capturedJql).not.toBeNull();
expect(capturedJql).toContain('updated >= -72h');
expect(capturedJql).toContain('project =');
}),
{ numRuns: 100 }
);
}, 60000);
});

View File

@@ -0,0 +1,146 @@
/**
* Example-Based Tests: Route Removal and Remaining Routes
*
* Feature: jira-api-compliance-cleanup
*
* Property 2: Search route is absent from router (Example)
* After the route removal, a POST request to /api/jira/search SHALL return HTTP 404.
* Validates: Requirements 1.1, 1.2
*
* Property 3: Existing routes remain functional after search route removal (Example)
* The routes GET /lookup/:issueKey, POST /sync-all, POST /:id/sync, and
* POST /create-in-jira SHALL continue to respond with non-404 status codes.
* Validates: Requirements 1.3, 1.4, 1.5, 1.6
*/
const http = require('http');
const express = require('express');
// Mock the auth middleware so routes don't require real sessions/cookies.
jest.mock('../middleware/auth', () => ({
requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'test', group: 'Admin' };
next();
},
requireGroup: () => (req, res, next) => next(),
}));
// Mock the audit log helper to be a no-op.
jest.mock('../helpers/auditLog', () => jest.fn());
// Mock the jiraApi helper — mark it as not configured so routes return 503
// (which is fine; we only care that they are NOT 404).
jest.mock('../helpers/jiraApi', () => ({
isConfigured: false,
getRateLimitStatus: jest.fn(() => ({
burst: { remaining: 60, limit: 60 },
daily: { remaining: 1440, limit: 1440 },
})),
}));
const createJiraTicketsRouter = require('../routes/jiraTickets');
// Minimal db mock — callback-style methods that return empty results.
function createMockDb() {
return {
get: jest.fn((_sql, _params, cb) => cb(null, null)),
all: jest.fn((_sql, _params, cb) => cb(null, [])),
run: jest.fn(function (_sql, _params, cb) {
if (typeof cb === 'function') cb.call({ lastID: 1, changes: 0 }, null);
}),
};
}
/**
* Helper: send an HTTP request to the test server and return { statusCode }.
*/
function request(server, method, path, body) {
return new Promise((resolve, reject) => {
const addr = server.address();
const options = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
// Consume the response body so the socket closes cleanly.
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({ statusCode: res.statusCode });
});
});
req.on('error', reject);
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
describe('Feature: jira-api-compliance-cleanup — route removal tests', () => {
let app;
let server;
beforeAll((done) => {
const db = createMockDb();
app = express();
app.use(express.json());
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
// Listen on a random available port.
server = app.listen(0, '127.0.0.1', done);
});
afterAll((done) => {
server.close(done);
});
// ---------------------------------------------------------------------------
// Property 2: POST /api/jira-tickets/search returns 404
// Validates: Requirements 1.1, 1.2
// ---------------------------------------------------------------------------
describe('Property 2: Search route is absent', () => {
it('POST /api/jira-tickets/search returns HTTP 404', async () => {
const res = await request(server, 'POST', '/api/jira-tickets/search', {
jql: 'project = TEST',
});
expect(res.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Property 3: Remaining routes respond with non-404 status codes
// Validates: Requirements 1.3, 1.4, 1.5, 1.6
// ---------------------------------------------------------------------------
describe('Property 3: Existing routes remain functional', () => {
it('GET /api/jira-tickets/lookup/:issueKey returns non-404', async () => {
const res = await request(server, 'GET', '/api/jira-tickets/lookup/TEST-1');
expect(res.statusCode).not.toBe(404);
});
it('POST /api/jira-tickets/sync-all returns non-404', async () => {
const res = await request(server, 'POST', '/api/jira-tickets/sync-all');
expect(res.statusCode).not.toBe(404);
});
it('POST /api/jira-tickets/:id/sync returns non-404', async () => {
const res = await request(server, 'POST', '/api/jira-tickets/1/sync');
expect(res.statusCode).not.toBe(404);
});
it('POST /api/jira-tickets/create-in-jira returns non-404', async () => {
const res = await request(server, 'POST', '/api/jira-tickets/create-in-jira', {
cve_id: 'CVE-2024-12345',
vendor: 'TestVendor',
summary: 'Test summary',
});
expect(res.statusCode).not.toBe(404);
});
});
});

Binary file not shown.

478
backend/db-schema.sql Normal file
View File

@@ -0,0 +1,478 @@
-- =============================================================================
-- 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' CHECK (status IN ('Open', 'In Progress', 'Closed')),
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_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')),
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;

46
backend/db.js Normal file
View File

@@ -0,0 +1,46 @@
// PostgreSQL Connection Pool
// All route files import this module instead of receiving a sqlite3 `db` parameter.
// Configured via DATABASE_URL environment variable.
// Ensure dotenv is loaded before accessing env vars
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const { Pool } = require('pg');
if (!process.env.DATABASE_URL) {
console.error('[DB] FATAL: DATABASE_URL environment variable is not set.');
console.error('[DB] Expected format: postgresql://user:password@host:port/database');
process.exit(1);
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Maximum connections in pool
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 5000, // Fail if connection takes >5s
});
// Log unexpected pool errors (connection drops, etc.)
pool.on('error', (err) => {
console.error('[DB Pool] Unexpected error on idle client:', err.message);
});
// Track active connections and warn when approaching 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--; });
// Health check — verify connection on startup
pool.query('SELECT NOW()')
.then(() => console.log('[DB Pool] Connected to PostgreSQL'))
.catch((err) => {
console.error('[DB Pool] Failed to connect:', err.message);
console.error('[DB Pool] Check DATABASE_URL and ensure Postgres is running on port 5433');
});
module.exports = pool;

View File

@@ -1,21 +1,19 @@
// Audit Log Helper // Audit Log Helper
// Fire-and-forget insert - never blocks the response // Fire-and-forget insert - never blocks the response
const pool = require('../db');
function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) { function logAudit({ userId, username, action, entityType, entityId, details, ipAddress }) {
const detailsStr = details && typeof details === 'object' const detailsStr = details && typeof details === 'object'
? JSON.stringify(details) ? JSON.stringify(details)
: details || null; : details || null;
db.run( pool.query(
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address) `INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null], [userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null]
(err) => { ).catch((err) => {
if (err) { console.error('Audit log error:', err.message);
console.error('Audit log error:', err.message); });
}
}
);
} }
module.exports = logAudit; module.exports = logAudit;

View File

@@ -304,7 +304,7 @@ async function searchIssuesByKeys(issueKeys, opts) {
// or similar, but key-based search is inherently scoped. We add updated // or similar, but key-based search is inherently scoped. We add updated
// clause for compliance. // clause for compliance.
const keyList = issueKeys.map(k => `"${k}"`).join(', '); const keyList = issueKeys.map(k => `"${k}"`).join(', ');
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`; const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`;
const fields = (opts && opts.fields) || DEFAULT_FIELDS; const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000); const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);

26
backend/helpers/teams.js Normal file
View File

@@ -0,0 +1,26 @@
// Shared BU team constants and validation
// Used by user management routes, auth middleware, and frontend-facing endpoints.
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
/**
* Parse and validate a comma-separated teams string.
* @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG')
* @returns {{ valid: boolean, teams: string[], invalid: string[] }}
*/
function validateTeams(teamsString) {
if (!teamsString || typeof teamsString !== 'string' || teamsString.trim() === '') {
return { valid: true, teams: [], invalid: [] };
}
const teams = teamsString.split(',').map(t => t.trim()).filter(Boolean);
const invalid = teams.filter(t => !KNOWN_TEAMS.includes(t));
return {
valid: invalid.length === 0,
teams,
invalid
};
}
module.exports = { KNOWN_TEAMS, validateTeams };

View File

@@ -1,7 +1,8 @@
// Authentication Middleware // Authentication Middleware
const pool = require('../db');
// Require authenticated user // Require authenticated user — no parameters needed, pool is imported directly
function requireAuth(db) { function requireAuth() {
return async (req, res, next) => { return async (req, res, next) => {
const sessionId = req.cookies?.session_id; const sessionId = req.cookies?.session_id;
@@ -10,19 +11,15 @@ function requireAuth(db) {
} }
try { try {
const session = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.is_active FROM sessions s
FROM sessions s JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`,
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, [sessionId]
[sessionId], );
(err, row) => {
if (err) reject(err); const session = rows[0];
else resolve(row);
}
);
});
if (!session) { if (!session) {
return res.status(401).json({ error: 'Session expired or invalid' }); return res.status(401).json({ error: 'Session expired or invalid' });
@@ -38,7 +35,8 @@ function requireAuth(db) {
username: session.username, username: session.username,
email: session.email, email: session.email,
role: session.role, role: session.role,
group: session.user_group group: session.user_group,
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
}; };
next(); next();

View File

@@ -0,0 +1,68 @@
// Migration: Add bu_teams column to users table
// Stores comma-separated BU team identifiers per user (e.g. 'STEAM,ACCESS-ENG')
// Existing users get empty string (admin must assign teams post-migration)
// Idempotent — safe to run multiple times
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const DB_FILE = path.join(__dirname, '..', 'cve_database.db');
/**
* Run the migration against the given database instance.
* Exported for testing with in-memory databases.
* @param {sqlite3.Database} db
* @returns {Promise<void>}
*/
function runMigration(db) {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Check if bu_teams column already exists
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
reject(err);
return;
}
const hasBuTeams = columns.some(col => col.name === 'bu_teams');
if (hasBuTeams) {
console.log('✓ bu_teams column already exists — skipping migration');
resolve();
return;
}
console.log('Adding bu_teams column to users table...');
db.run(
`ALTER TABLE users ADD COLUMN bu_teams TEXT NOT NULL DEFAULT ''`,
(err) => {
if (err) {
reject(err);
return;
}
console.log('✓ Added bu_teams column (default: empty string)');
console.log(' Note: Admin must assign teams to existing users via user management UI');
resolve();
}
);
});
});
});
}
// Run directly if executed as a script
if (require.main === module) {
const db = new sqlite3.Database(DB_FILE);
runMigration(db)
.then(() => {
console.log('Migration complete.');
db.close();
})
.catch((err) => {
console.error('Migration failed:', err.message);
db.close();
process.exit(1);
});
}
module.exports = { runMigration };

View File

@@ -1,5 +1,6 @@
// routes/archerTickets.js // routes/archerTickets.js
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
@@ -13,42 +14,43 @@ function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
} }
function createArcherTicketsRouter(db) { function createArcherTicketsRouter() {
const router = express.Router(); const router = express.Router();
// Get all Archer tickets (with optional filters) // Get all Archer tickets (with optional filters)
router.get('/', requireAuth(db), (req, res) => { router.get('/', requireAuth(), async (req, res) => {
const { cve_id, vendor, status } = req.query; const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM archer_tickets WHERE 1=1'; let query = 'SELECT * FROM archer_tickets WHERE 1=1';
const params = []; const params = [];
let paramIndex = 1;
if (cve_id) { if (cve_id) {
query += ' AND cve_id = ?'; query += ` AND cve_id = $${paramIndex++}`;
params.push(cve_id); params.push(cve_id);
} }
if (vendor) { if (vendor) {
query += ' AND vendor = ?'; query += ` AND vendor = $${paramIndex++}`;
params.push(vendor); params.push(vendor);
} }
if (status) { if (status) {
query += ' AND status = ?'; query += ` AND status = $${paramIndex++}`;
params.push(status); params.push(status);
} }
query += ' ORDER BY created_at DESC'; query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => { try {
if (err) { const { rows } = await pool.query(query, params);
console.error('Error fetching Archer tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows); res.json(rows);
}); } catch (err) {
console.error('Error fetching Archer tickets:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
// Create Archer ticket // Create Archer ticket
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { exc_number, archer_url, status, cve_id, vendor } = req.body; const { exc_number, archer_url, status, cve_id, vendor } = req.body;
// Validation // Validation
@@ -73,38 +75,38 @@ function createArcherTicketsRouter(db) {
const validatedStatus = status || 'Draft'; const validatedStatus = status || 'Draft';
db.run( try {
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id], VALUES ($1, $2, $3, $4, $5, $6)
function(err) { RETURNING id`,
if (err) { [exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id]
console.error('Error creating Archer ticket:', err); );
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
}
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
action: 'CREATE_ARCHER_TICKET', action: 'CREATE_ARCHER_TICKET',
entityType: 'archer_ticket', entityType: 'archer_ticket',
entityId: String(this.lastID), entityId: String(rows[0].id),
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
message: 'Archer ticket created successfully' message: 'Archer ticket created successfully'
}); });
} catch (err) {
console.error('Error creating Archer ticket:', err);
if (err.code === '23505') {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
} }
); res.status(500).json({ error: 'Internal server error.' });
}
}); });
// Update Archer ticket // Update Archer ticket
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { exc_number, archer_url, status } = req.body; const { exc_number, archer_url, status } = req.body;
@@ -124,29 +126,27 @@ function createArcherTicketsRouter(db) {
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' }); return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
} }
// Get existing ticket try {
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
if (err) { const existing = rows[0];
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
const updates = []; const updates = [];
const params = []; const params = [];
let paramIndex = 1;
if (exc_number !== undefined) { if (exc_number !== undefined) {
updates.push('exc_number = ?'); updates.push(`exc_number = $${paramIndex++}`);
params.push(exc_number.trim()); params.push(exc_number.trim());
} }
if (archer_url !== undefined) { if (archer_url !== undefined) {
updates.push('archer_url = ?'); updates.push(`archer_url = $${paramIndex++}`);
params.push(archer_url || null); params.push(archer_url || null);
} }
if (status !== undefined) { if (status !== undefined) {
updates.push('status = ?'); updates.push(`status = $${paramIndex++}`);
params.push(status); params.push(status);
} }
@@ -154,73 +154,47 @@ function createArcherTicketsRouter(db) {
return res.status(400).json({ error: 'No fields to update.' }); return res.status(400).json({ error: 'No fields to update.' });
} }
updates.push('updated_at = CURRENT_TIMESTAMP'); updates.push('updated_at = NOW()');
params.push(id); params.push(id);
db.run( const result = await pool.query(
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`, `UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
params, params
function(err) {
if (err) {
console.error(err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
}
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
action: 'UPDATE_ARCHER_TICKET',
entityType: 'archer_ticket',
entityId: String(id),
details: { before: existing, changes: req.body },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
}
); );
});
});
// Helper: perform the actual Archer ticket deletion logAudit({
function performArcherDelete(db, req, res, id, ticket) {
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'DELETE_ARCHER_TICKET', action: 'UPDATE_ARCHER_TICKET',
entityType: 'archer_ticket', entityType: 'archer_ticket',
entityId: String(id), entityId: String(id),
details: { deleted: ticket }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
}); });
res.json({ message: 'Archer ticket deleted successfully' }); res.json({ message: 'Archer ticket updated successfully', changes: result.rowCount });
}); } catch (err) {
} console.error(err);
if (err.code === '23505') {
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
}
res.status(500).json({ error: 'Internal server error.' });
}
});
// Delete Archer ticket // Delete Archer ticket
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'Archer ticket not found.' }); return res.status(404).json({ error: 'Archer ticket not found.' });
} }
// Admin bypasses all delete restrictions // Admin bypasses all delete restrictions
if (req.user.group === 'Admin') { if (req.user.group === 'Admin') {
return performArcherDelete(db, req, res, id, ticket); return performArcherDelete();
} }
// Standard_User: ownership check // Standard_User: ownership check
@@ -230,53 +204,63 @@ function createArcherTicketsRouter(db) {
// Standard_User: compliance linkage check // Standard_User: compliance linkage check
const excNumber = ticket.exc_number; const excNumber = ticket.exc_number;
db.all( try {
`SELECT ci.id, ci.extra_json const { rows: compLinks } = await pool.query(
FROM compliance_items ci `SELECT ci.id, ci.extra_json
JOIN compliance_uploads cu ON ci.upload_id = cu.id FROM compliance_items ci
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, JOIN compliance_uploads cu ON ci.upload_id = cu.id
[`%${excNumber}%`], WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
(compErr, compLinks) => { [`%${excNumber}%`]
// If compliance_items table doesn't exist yet, treat as no linkage );
if (compErr && compErr.message && compErr.message.includes('no such table')) {
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => { const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || ''; const json = cl.extra_json || '';
return json.includes(excNumber); return json.includes(excNumber);
}); });
if (isLinked) { if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' }); return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performArcherDelete(db, req, res, id, ticket);
} }
); } catch (compErr) {
}); if (!compErr.message.includes('does not exist')) throw compErr;
}
return performArcherDelete();
async function performArcherDelete() {
await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]);
logAudit({
userId: req.user.id,
action: 'DELETE_ARCHER_TICKET',
entityType: 'archer_ticket',
entityId: String(id),
details: { deleted: ticket },
ipAddress: req.ip
});
res.json({ message: 'Archer ticket deleted successfully' });
}
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
// GET /status-trend — ticket counts grouped by creation date + status // GET /status-trend — ticket counts grouped by creation date + status
// Used for time-based Archer pipeline chart on the Compliance page. router.get('/status-trend', requireAuth(), async (req, res) => {
router.get('/status-trend', requireAuth(db), (req, res) => { try {
db.all( const { rows } = await pool.query(
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count `SELECT DATE(created_at) AS date, status, COUNT(*) AS count
FROM archer_tickets FROM archer_tickets
GROUP BY DATE(created_at), status GROUP BY DATE(created_at), status
ORDER BY date ASC`, ORDER BY date ASC`
[], );
(err, rows) => { res.json({ statusTrend: rows });
if (err) { } catch (err) {
console.error('Error fetching Archer status trend:', err); console.error('Error fetching Archer status trend:', err);
return res.status(500).json({ error: 'Internal server error.' }); res.status(500).json({ error: 'Internal server error.' });
} }
res.json({ statusTrend: rows });
}
);
}); });
return router; return router;

View File

@@ -1,34 +1,24 @@
// Atlas InfoSec Action Plans Routes // Atlas InfoSec Action Plans Routes
// Proxies CRUD operations to the Atlas API and maintains a local SQLite cache // Proxies CRUD operations to the Atlas API and maintains a local cache
// for fast badge rendering on the ReportingPage. // for fast badge rendering on the ReportingPage.
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
const fs = require('fs');
const path = require('path');
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion']; const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
// --------------------------------------------------------------------------- // Diagnostic log helper
// DB helpers — promise wrappers for callback-based SQLite API function syncLog(msg) {
// --------------------------------------------------------------------------- const line = `${new Date().toISOString()} ${msg}\n`;
function dbRun(db, sql, params = []) { try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
return new Promise((resolve, reject) => { console.log(msg);
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,7 +35,7 @@ function aggregateAtlasMetrics(rows) {
}; };
for (const row of rows) { for (const row of rows) {
if (row.has_action_plan === 1) { if (row.has_action_plan === true || row.has_action_plan === 1) {
result.hostsWithPlans++; result.hostsWithPlans++;
} else { } else {
result.hostsWithoutPlans++; result.hostsWithoutPlans++;
@@ -55,7 +45,6 @@ function aggregateAtlasMetrics(rows) {
try { try {
plans = JSON.parse(row.plans_json); plans = JSON.parse(row.plans_json);
} catch (e) { } catch (e) {
// Invalid JSON — skip plan details for this row
continue; continue;
} }
@@ -63,11 +52,9 @@ function aggregateAtlasMetrics(rows) {
for (const plan of plans) { for (const plan of plans) {
result.totalPlans++; result.totalPlans++;
if (plan.plan_type) { if (plan.plan_type) {
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1; result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
} }
if (plan.status) { if (plan.status) {
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1; result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
} }
@@ -80,28 +67,17 @@ function aggregateAtlasMetrics(rows) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createAtlasRouter(db, requireAuth) { function createAtlasRouter() {
const router = express.Router(); const router = express.Router();
// -----------------------------------------------------------------------
// GET /metrics // GET /metrics
// Return aggregated Atlas metrics for chart rendering. router.get('/metrics', requireAuth(), async (req, res) => {
// Auth: any authenticated user
//
// Response 200:
// { totalHosts: number, hostsWithPlans: number, hostsWithoutPlans: number,
// plansByType: { [type: string]: number }, plansByStatus: { [status: string]: number },
// totalPlans: number }
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — DB query failure
// -----------------------------------------------------------------------
router.get('/metrics', requireAuth(db), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
const rows = await dbAll(db, const { rows } = await pool.query(
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
); );
const metrics = aggregateAtlasMetrics(rows); const metrics = aggregateAtlasMetrics(rows);
@@ -112,24 +88,15 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// GET /status // GET /status
// Return all cached Atlas rows for badge rendering. router.get('/status', requireAuth(), async (req, res) => {
// Auth: any authenticated user
//
// Response 200:
// [ { host_id: number, has_action_plan: 0|1, plan_count: number, synced_at: string }, ... ]
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — DB query failure
// -----------------------------------------------------------------------
router.get('/status', requireAuth(db), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
const rows = await dbAll(db, const { rows } = await pool.query(
`SELECT host_id, has_action_plan, plan_count, synced_at FROM atlas_action_plans_cache` `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
); );
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
@@ -138,49 +105,23 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /sync // POST /sync
// Sync Atlas action plan data for all hosts found in the Ivanti cache. router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// Auth: Admin or Standard_User
//
// Request body: none
// Response 200:
// { synced: number, withPlans: number, failed: number }
// Response 503: { error: string } — Atlas not configured
// Response 500: { error: string } — sync failure or Ivanti cache parse error
// -----------------------------------------------------------------------
router.post('/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
// 1. Read Ivanti findings cache and extract unique non-null hostIds // Read Ivanti findings and extract unique non-null hostIds
const cacheRow = await dbGet(db, `SELECT findings_json FROM ivanti_findings_cache WHERE id = 1`); const { rows: findingsRows } = await pool.query(
if (!cacheRow || !cacheRow.findings_json) { `SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
return res.json({ synced: 0, withPlans: 0, failed: 0 }); );
} const hostIds = findingsRows.map(r => r.host_id);
let findings;
try {
findings = JSON.parse(cacheRow.findings_json);
} catch (parseErr) {
return res.status(500).json({ error: 'Failed to parse Ivanti findings cache.' });
}
const hostIdSet = new Set();
for (const f of findings) {
if (f.hostId != null && typeof f.hostId === 'number' && f.hostId > 0) {
hostIdSet.add(f.hostId);
}
}
const hostIds = [...hostIdSet];
if (hostIds.length === 0) { if (hostIds.length === 0) {
return res.json({ synced: 0, withPlans: 0, failed: 0 }); return res.json({ synced: 0, withPlans: 0, failed: 0 });
} }
// 2. Process hosts in batches of 5 concurrent requests
let synced = 0; let synced = 0;
let withPlans = 0; let withPlans = 0;
let failed = 0; let failed = 0;
@@ -209,7 +150,6 @@ function createAtlasRouter(db, requireAuth) {
let activePlans = []; let activePlans = [];
try { try {
const parsed = JSON.parse(result.body); const parsed = JSON.parse(result.body);
// Atlas returns { active: [...], inactive: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
activePlans = Array.isArray(parsed.active) ? parsed.active : []; activePlans = Array.isArray(parsed.active) ? parsed.active : [];
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : []; const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
@@ -223,19 +163,40 @@ function createAtlasRouter(db, requireAuth) {
activePlans = []; activePlans = [];
} }
// Badge counts only active plans — inactive are historical
const planCount = activePlans.length; const planCount = activePlans.length;
const hasActionPlan = planCount > 0 ? 1 : 0; const hasActionPlan = planCount > 0;
try { try {
await dbRun(db, if (!hasActionPlan) {
const { rows: existingRows } = await pool.query(
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`,
[hostId]
);
const existing = existingRows[0];
if (existing && existing.has_action_plan === true) {
let existingPlans = [];
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
if (hasBulkStub) {
const ageMs = Date.now() - new Date(existing.synced_at).getTime();
const TEN_MINUTES = 10 * 60 * 1000;
if (ageMs < TEN_MINUTES) {
synced++;
withPlans++;
continue;
}
}
}
}
await pool.query(
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
VALUES (?, ?, ?, ?, datetime('now')) VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT(host_id) DO UPDATE SET ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = excluded.has_action_plan, has_action_plan = EXCLUDED.has_action_plan,
plan_count = excluded.plan_count, plan_count = EXCLUDED.plan_count,
plans_json = excluded.plans_json, plans_json = EXCLUDED.plans_json,
synced_at = excluded.synced_at`, synced_at = EXCLUDED.synced_at`,
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)] [hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
); );
} catch (dbErr) { } catch (dbErr) {
@@ -251,8 +212,7 @@ function createAtlasRouter(db, requireAuth) {
} }
} }
// 3. Log audit entry logAudit({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_SYNC', action: 'ATLAS_SYNC',
@@ -269,18 +229,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// GET /hosts/:hostId/action-plans // GET /hosts/:hostId/action-plans
// Proxy to Atlas API — returns live action plan data for a single host. router.get('/hosts/:hostId/action-plans', requireAuth(), async (req, res) => {
// Auth: any authenticated user
//
// Params: hostId (positive integer)
// Response 2xx: proxied Atlas response body (parsed JSON or raw)
// Response 400: { error: string } — invalid hostId
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.get('/hosts/:hostId/action-plans', requireAuth(db), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
@@ -292,23 +242,13 @@ function createAtlasRouter(db, requireAuth) {
try { try {
const result = await atlasGet('/hosts/' + hostId + '/action-plans'); const result = await atlasGet('/hosts/' + hostId + '/action-plans');
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
// Forward non-2xx Atlas responses to the client
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -317,22 +257,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// PUT /hosts/:hostId/action-plans // PUT /hosts/:hostId/action-plans
// Create a new action plan for a host. router.put('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// Auth: Admin or Standard_User
//
// Params: hostId (positive integer)
// Request body:
// { plan_type: string (one of VALID_PLAN_TYPES), commit_date: string (YYYY-MM-DD),
// qualys_id?: string, active_host_findings_id?: string,
// jira_vnr?: string, archer_exc?: string }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid hostId, plan_type, or commit_date
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.put('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
@@ -343,11 +269,9 @@ function createAtlasRouter(db, requireAuth) {
} }
const { plan_type, commit_date } = req.body || {}; const { plan_type, commit_date } = req.body || {};
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) { if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') }); return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
} }
if (!commit_date || !DATE_PATTERN.test(commit_date)) { if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' }); return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
} }
@@ -355,7 +279,7 @@ function createAtlasRouter(db, requireAuth) {
try { try {
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body); const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_CREATE_PLAN', action: 'ATLAS_CREATE_PLAN',
@@ -367,19 +291,11 @@ function createAtlasRouter(db, requireAuth) {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -388,20 +304,8 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// PATCH /hosts/:hostId/action-plans // PATCH /hosts/:hostId/action-plans
// Update an existing action plan for a host. router.patch('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// Auth: Admin or Standard_User
//
// Params: hostId (positive integer)
// Request body:
// { action_plan_id: string (non-empty), updates: object (non-null, non-array) }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid hostId, action_plan_id, or updates
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.patch('/hosts/:hostId/action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
@@ -412,11 +316,9 @@ function createAtlasRouter(db, requireAuth) {
} }
const { action_plan_id, updates } = req.body || {}; const { action_plan_id, updates } = req.body || {};
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') { if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' }); return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
} }
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ error: 'updates is required and must be an object' }); return res.status(400).json({ error: 'updates is required and must be an object' });
} }
@@ -424,7 +326,7 @@ function createAtlasRouter(db, requireAuth) {
try { try {
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body); const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'ATLAS_UPDATE_PLAN', action: 'ATLAS_UPDATE_PLAN',
@@ -436,19 +338,11 @@ function createAtlasRouter(db, requireAuth) {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -457,41 +351,24 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /hosts/bulk-action-plans // POST /hosts/bulk-action-plans
// Create action plans for multiple hosts at once. router.post('/hosts/bulk-action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// Auth: Admin or Standard_User
//
// Request body:
// { host_ids: number[] (non-empty, positive integers),
// plan_type: string (one of VALID_PLAN_TYPES),
// commit_date: string (YYYY-MM-DD) }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid host_ids, plan_type, or commit_date
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.post('/hosts/bulk-action-plans', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
const { host_ids, plan_type, commit_date } = req.body || {}; const { host_ids, plan_type, commit_date } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) { if (!Array.isArray(host_ids) || host_ids.length === 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
} }
for (const id of host_ids) { for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
} }
} }
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) { if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') }); return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
} }
if (!commit_date || !DATE_PATTERN.test(commit_date)) { if (!commit_date || !DATE_PATTERN.test(commit_date)) {
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' }); return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
} }
@@ -501,19 +378,55 @@ function createAtlasRouter(db, requireAuth) {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) { // Optimistically update local cache
body = result.body; for (const hid of host_ids) {
try {
const { rows: existingRows } = await pool.query(
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
[hid]
);
const existing = existingRows[0];
let existingPlans = [];
if (existing && existing.plans_json) {
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
}
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
const updatedPlans = [...existingPlans, stubPlan];
const newCount = updatedPlans.length;
await pool.query(
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
VALUES ($1, true, $2, $3, NOW())
ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = true,
plan_count = EXCLUDED.plan_count,
plans_json = EXCLUDED.plans_json,
synced_at = EXCLUDED.synced_at`,
[hid, newCount, JSON.stringify(updatedPlans)]
);
} catch (cacheErr) {
console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message);
}
} }
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'ATLAS_BULK_CREATE_PLANS',
entityType: 'atlas_action_plan',
entityId: null,
details: { host_ids, plan_type, commit_date, count: host_ids.length },
ipAddress: req.ip
});
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {
@@ -522,29 +435,16 @@ function createAtlasRouter(db, requireAuth) {
} }
}); });
// -----------------------------------------------------------------------
// POST /hosts/vulnerabilities // POST /hosts/vulnerabilities
// Fetch active Ivanti vulnerabilities for multiple hosts from Atlas. router.post('/hosts/vulnerabilities', requireAuth(), async (req, res) => {
// Used by the bulk action plan modal to populate the qualys_id dropdown.
// Auth: any authenticated user
//
// Request body: { host_ids: number[] }
// Response 2xx: proxied Atlas response body
// Response 400: { error: string } — invalid host_ids
// Response 503: { error: string } — Atlas not configured
// Response 502: { error: string } — Atlas API unreachable
// -----------------------------------------------------------------------
router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
const { host_ids } = req.body || {}; const { host_ids } = req.body || {};
if (!Array.isArray(host_ids) || host_ids.length === 0) { if (!Array.isArray(host_ids) || host_ids.length === 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
} }
for (const id of host_ids) { for (const id of host_ids) {
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
@@ -554,24 +454,13 @@ function createAtlasRouter(db, requireAuth) {
try { try {
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 }); const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length);
console.log('[Atlas] Response preview:', result.body?.substring(0, 500));
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
body = JSON.parse(result.body);
} catch (e) {
body = result.body;
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (e) {
errorBody = { error: result.body };
}
res.status(result.status).json(errorBody); res.status(result.status).json(errorBody);
} }
} catch (err) { } catch (err) {

View File

@@ -1,11 +1,13 @@
// Audit Log Routes (Admin only) // Audit Log Routes (Admin only)
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
function createAuditLogRouter(db, requireAuth, requireGroup) { function createAuditLogRouter() {
const router = express.Router(); const router = express.Router();
// All routes require Admin group // All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin')); router.use(requireAuth(), requireGroup('Admin'));
// Get paginated audit logs with filters // Get paginated audit logs with filters
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
@@ -24,25 +26,26 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
let where = []; let where = [];
let params = []; let params = [];
let paramIndex = 1;
if (user) { if (user) {
where.push('username LIKE ?'); where.push(`username ILIKE $${paramIndex++}`);
params.push(`%${user}%`); params.push(`%${user}%`);
} }
if (action) { if (action) {
where.push('action = ?'); where.push(`action = $${paramIndex++}`);
params.push(action); params.push(action);
} }
if (entityType) { if (entityType) {
where.push('entity_type = ?'); where.push(`entity_type = $${paramIndex++}`);
params.push(entityType); params.push(entityType);
} }
if (startDate) { if (startDate) {
where.push('created_at >= ?'); where.push(`created_at >= $${paramIndex++}`);
params.push(startDate); params.push(startDate);
} }
if (endDate) { if (endDate) {
where.push('created_at <= ?'); where.push(`created_at <= $${paramIndex++}`);
params.push(endDate + ' 23:59:59'); params.push(endDate + ' 23:59:59');
} }
@@ -50,36 +53,25 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
try { try {
// Get total count // Get total count
const countRow = await new Promise((resolve, reject) => { const countResult = await pool.query(
db.get( `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`, params
params, );
(err, row) => { const total = parseInt(countResult.rows[0].total);
if (err) reject(err);
else resolve(row);
}
);
});
// Get paginated results // Get paginated results
const rows = await new Promise((resolve, reject) => { const dataResult = await pool.query(
db.all( `SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]
[...params, pageSize, offset], );
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json({ res.json({
logs: rows, logs: dataResult.rows,
pagination: { pagination: {
page: parseInt(page), page: parseInt(page),
limit: pageSize, limit: pageSize,
total: countRow.total, total: total,
totalPages: Math.ceil(countRow.total / pageSize) totalPages: Math.ceil(total / pageSize)
} }
}); });
} catch (err) { } catch (err) {
@@ -91,16 +83,9 @@ function createAuditLogRouter(db, requireAuth, requireGroup) {
// Get distinct action types for filter dropdown // Get distinct action types for filter dropdown
router.get('/actions', async (req, res) => { router.get('/actions', async (req, res) => {
try { try {
const rows = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.all( 'SELECT DISTINCT action FROM audit_logs ORDER BY action'
'SELECT DISTINCT action FROM audit_logs ORDER BY action', );
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json(rows.map(r => r.action)); res.json(rows.map(r => r.action));
} catch (err) { } catch (err) {
console.error('Audit log actions error:', err); console.error('Audit log actions error:', err);

View File

@@ -3,6 +3,7 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
@@ -13,7 +14,7 @@ const loginLimiter = rateLimit({
message: { error: 'Too many login attempts. Please try again in 15 minutes.' } message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
}); });
function createAuthRouter(db, logAudit) { function createAuthRouter(logAudit) {
const router = express.Router(); const router = express.Router();
/** /**
@@ -39,19 +40,14 @@ function createAuthRouter(db, logAudit) {
try { try {
// Find user // Find user
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT * FROM users WHERE username = $1',
'SELECT * FROM users WHERE username = ?', [username]
[username], );
(err, row) => { const user = rows[0];
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) { if (!user) {
logAudit(db, { logAudit({
userId: null, userId: null,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -64,7 +60,7 @@ function createAuthRouter(db, logAudit) {
} }
if (!user.is_active) { if (!user.is_active) {
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -79,7 +75,7 @@ function createAuthRouter(db, logAudit) {
// Verify password // Verify password
const validPassword = await bcrypt.compare(password, user.password_hash); const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) { if (!validPassword) {
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: username, username: username,
action: 'login_failed', action: 'login_failed',
@@ -96,28 +92,16 @@ function createAuthRouter(db, logAudit) {
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create session // Create session
await new Promise((resolve, reject) => { await pool.query(
db.run( 'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)',
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)', [sessionId, user.id, expiresAt.toISOString()]
[sessionId, user.id, expiresAt.toISOString()], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Update last login // Update last login
await new Promise((resolve, reject) => { await pool.query(
db.run( 'UPDATE users SET last_login = NOW() WHERE id = $1',
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]
[user.id], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Set cookie // Set cookie
res.cookie('session_id', sessionId, { res.cookie('session_id', sessionId, {
@@ -127,7 +111,7 @@ function createAuthRouter(db, logAudit) {
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
}); });
logAudit(db, { logAudit({
userId: user.id, userId: user.id,
username: user.username, username: user.username,
action: 'login', action: 'login',
@@ -143,7 +127,8 @@ function createAuthRouter(db, logAudit) {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
group: user.user_group group: user.user_group,
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
} }
}); });
} catch (err) { } catch (err) {
@@ -165,27 +150,31 @@ function createAuthRouter(db, logAudit) {
if (sessionId) { if (sessionId) {
// Look up user before deleting session // Look up user before deleting session
const session = await new Promise((resolve) => { let session = null;
db.get( try {
const { rows } = await pool.query(
`SELECT u.id as user_id, u.username FROM sessions s `SELECT u.id as user_id, u.username FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = ?`, WHERE s.session_id = $1`,
[sessionId], [sessionId]
(err, row) => resolve(row || null)
); );
}); session = rows[0] || null;
} catch (err) {
// Non-critical — proceed with logout
}
// Delete session from database // Delete session from database
await new Promise((resolve) => { try {
db.run( await pool.query(
'DELETE FROM sessions WHERE session_id = ?', 'DELETE FROM sessions WHERE session_id = $1',
[sessionId], [sessionId]
() => resolve()
); );
}); } catch (err) {
// Non-critical — proceed with logout
}
if (session) { if (session) {
logAudit(db, { logAudit({
userId: session.user_id, userId: session.user_id,
username: session.username, username: session.username,
action: 'logout', action: 'logout',
@@ -220,19 +209,15 @@ function createAuthRouter(db, logAudit) {
} }
try { try {
const session = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.is_active FROM sessions s
FROM sessions s JOIN users u ON s.user_id = u.id
JOIN users u ON s.user_id = u.id WHERE s.session_id = $1 AND s.expires_at > NOW()`,
WHERE s.session_id = ? AND s.expires_at > datetime('now')`, [sessionId]
[sessionId], );
(err, row) => {
if (err) reject(err); const session = rows[0];
else resolve(row);
}
);
});
if (!session) { if (!session) {
res.clearCookie('session_id'); res.clearCookie('session_id');
@@ -249,7 +234,8 @@ function createAuthRouter(db, logAudit) {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
group: session.user_group group: session.user_group,
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
} }
}); });
} catch (err) { } catch (err) {
@@ -269,18 +255,14 @@ function createAuthRouter(db, logAudit) {
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie) * @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
* @returns {object} 500 - { error: 'Failed to fetch profile' } * @returns {object} 500 - { error: 'Failed to fetch profile' }
*/ */
router.get('/profile', requireAuth(db), async (req, res) => { router.get('/profile', requireAuth(), async (req, res) => {
try { try {
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1',
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?', [req.user.id]
[req.user.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user || !user.is_active) { if (!user || !user.is_active) {
res.clearCookie('session_id'); res.clearCookie('session_id');
@@ -325,7 +307,7 @@ function createAuthRouter(db, logAudit) {
* @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' } * @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' }
* @returns {object} 500 - { error: 'Failed to change password' } * @returns {object} 500 - { error: 'Failed to change password' }
*/ */
router.post('/change-password', requireAuth(db), passwordChangeLimiter, async (req, res) => { router.post('/change-password', requireAuth(), passwordChangeLimiter, async (req, res) => {
const { currentPassword, newPassword } = req.body; const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) { if (!currentPassword || !newPassword) {
@@ -338,16 +320,12 @@ function createAuthRouter(db, logAudit) {
try { try {
// Fetch user's password hash and active status // Fetch user's password hash and active status
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT password_hash, is_active FROM users WHERE id = $1',
'SELECT password_hash, is_active FROM users WHERE id = ?', [req.user.id]
[req.user.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user || !user.is_active) { if (!user || !user.is_active) {
return res.status(401).json({ error: 'Account is disabled' }); return res.status(401).json({ error: 'Account is disabled' });
@@ -361,18 +339,12 @@ function createAuthRouter(db, logAudit) {
// Hash new password and update // Hash new password and update
const newHash = await bcrypt.hash(newPassword, 10); const newHash = await bcrypt.hash(newPassword, 10);
await new Promise((resolve, reject) => { await pool.query(
db.run( 'UPDATE users SET password_hash = $1 WHERE id = $2',
'UPDATE users SET password_hash = ? WHERE id = ?', [newHash, req.user.id]
[newHash, req.user.id], );
(err) => {
if (err) reject(err);
else resolve();
}
);
});
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'password_change', action: 'password_change',
@@ -399,17 +371,9 @@ function createAuthRouter(db, logAudit) {
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' } * @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
* @returns {object} 500 - { error: 'Cleanup failed' } * @returns {object} 500 - { error: 'Cleanup failed' }
*/ */
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => { router.post('/cleanup-sessions', requireAuth(), requireGroup('Admin'), async (req, res) => {
try { try {
await new Promise((resolve, reject) => { await pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
db.run(
"DELETE FROM sessions WHERE expires_at < datetime('now')",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
res.json({ message: 'Expired sessions cleaned up' }); res.json({ message: 'Expired sessions cleaned up' });
} catch (err) { } catch (err) {
console.error('Session cleanup error:', err); console.error('Session cleanup error:', err);

View File

@@ -3,7 +3,8 @@
// the two-step update_token flow for mutations. // the two-step update_token flow for mutations.
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { const {
isConfigured, isConfigured,
@@ -16,21 +17,6 @@ const {
redirectAsset, redirectAsset,
} = require('../helpers/cardApi'); } = require('../helpers/cardApi');
// ---------------------------------------------------------------------------
// DB helpers — promise wrappers for callback-based SQLite API
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Error classification — maps CARD API / token errors to client responses // Error classification — maps CARD API / token errors to client responses
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,7 +24,6 @@ function handleCardError(err, res) {
const msg = err.message || String(err); const msg = err.message || String(err);
console.error('[card-api]', msg); console.error('[card-api]', msg);
// Token endpoint errors (from acquireToken rejections)
if (msg.includes('Token acquisition failed')) { if (msg.includes('Token acquisition failed')) {
if (msg.includes('HTTP 401')) { if (msg.includes('HTTP 401')) {
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' }); return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
@@ -51,7 +36,6 @@ function handleCardError(err, res) {
} }
} }
// API call errors (after automatic 401 retry in helper)
if (msg.includes('401')) { if (msg.includes('401')) {
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' }); return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
} }
@@ -59,73 +43,47 @@ function handleCardError(err, res) {
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' }); return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
} }
// Catch-all
return res.status(502).json({ error: 'CARD API request failed.', details: msg }); return res.status(502).json({ error: 'CARD API request failed.', details: msg });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router factory // Router factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createCardApiRouter(db, requireAuth) { function createCardApiRouter() {
const router = express.Router(); const router = express.Router();
// -------------------------------------------------------------------
// GET /status // GET /status
// Returns whether the CARD API integration is configured. router.get('/status', requireAuth(), (req, res) => {
// -------------------------------------------------------------------
router.get('/status', requireAuth(db), (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
configured: false,
error: 'CARD API is not configured.',
missingVars,
});
} }
res.json({ configured: true }); res.json({ configured: true });
}); });
// -------------------------------------------------------------------
// GET /teams // GET /teams
// Proxy CARD teams list. router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/teams', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
try { try {
const result = await getTeams(); const result = await getTeams();
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// CARD API wraps teams in { teams: [...], response_time: ... }
const teams = Array.isArray(body) ? body : (body && body.teams) || []; const teams = Array.isArray(body) ? body : (body && body.teams) || [];
return res.json(teams); return res.json(teams);
} }
// Forward CARD error status
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// GET /teams/:teamName/assets // GET /teams/:teamName/assets
// Proxy team assets with required disposition filter. router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/teams/:teamName/assets', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -146,20 +104,15 @@ function createCardApiRouter(db, requireAuth) {
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
// Audit log for asset search (fire-and-forget)
let resultCount = 0; let resultCount = 0;
if (body && typeof body === 'object' && typeof body.total === 'number') { if (body && typeof body === 'object' && typeof body.total === 'number') {
resultCount = body.total; resultCount = body.total;
} else if (body && Array.isArray(body.assets)) { } else if (body && Array.isArray(body.assets)) {
resultCount = body.assets.length; resultCount = body.assets.length;
} }
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'card_search', action: 'card_search',
@@ -173,22 +126,15 @@ function createCardApiRouter(db, requireAuth) {
} }
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// GET /owner/:assetId // GET /owner/:assetId
// Proxy owner record lookup. router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.get('/owner/:assetId', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -197,34 +143,21 @@ function createCardApiRouter(db, requireAuth) {
try { try {
const result = await getOwner(assetId); const result = await getOwner(assetId);
if (result.ok) { if (result.ok) {
let body; let body;
try { try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
body = JSON.parse(result.body);
} catch (_) {
body = result.body;
}
return res.json(body); return res.json(body);
} }
let errorBody; let errorBody;
try { try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
errorBody = JSON.parse(result.body);
} catch (_) {
errorBody = { error: result.body };
}
return res.status(result.status).json(errorBody); return res.status(result.status).json(errorBody);
} catch (err) { } catch (err) {
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/confirm // POST /queue/:queueItemId/confirm
// Confirm asset to a team via CARD API. router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/confirm', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -232,7 +165,6 @@ function createCardApiRouter(db, requireAuth) {
const { queueItemId } = req.params; const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body; const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' }); return res.status(400).json({ error: 'teamName is required.' });
} }
@@ -241,11 +173,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); return res.status(404).json({ error: 'Queue item not found.' });
@@ -254,20 +186,10 @@ function createCardApiRouter(db, requireAuth) {
return res.status(400).json({ error: 'Only pending queue items can be executed.' }); return res.status(400).json({ error: 'Only pending queue items can be executed.' });
} }
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -279,82 +201,39 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
} }
// Step 2: Execute confirm mutation
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
if (confirmResult.ok) { if (confirmResult.ok) {
// Update queue item to complete await pool.query(
await dbRun(db, "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
// Audit log (fire-and-forget) logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_confirm',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse }); return res.json({ success: true, cardResponse });
} }
// Mutation failed — leave queue item as pending
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`; const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
return res.status(confirmResult.status).json(errorBody); return res.status(confirmResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Confirm error:', err.message); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/decline // POST /queue/:queueItemId/decline
// Decline asset from a team via CARD API. router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/decline', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -362,7 +241,6 @@ function createCardApiRouter(db, requireAuth) {
const { queueItemId } = req.params; const { queueItemId } = req.params;
const { teamName, assetId, comment } = req.body; const { teamName, assetId, comment } = req.body;
// Validate required fields
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
return res.status(400).json({ error: 'teamName is required.' }); return res.status(400).json({ error: 'teamName is required.' });
} }
@@ -371,11 +249,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); return res.status(404).json({ error: 'Queue item not found.' });
@@ -384,20 +262,10 @@ function createCardApiRouter(db, requireAuth) {
return res.status(400).json({ error: 'Only pending queue items can be executed.' }); return res.status(400).json({ error: 'Only pending queue items can be executed.' });
} }
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -409,80 +277,39 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
} }
// Step 2: Execute decline mutation
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
if (declineResult.ok) { if (declineResult.ok) {
await dbRun(db, await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
logAudit(db, { logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip });
userId: req.user.id,
username: req.user.username,
action: 'card_decline',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse }); return res.json({ success: true, cardResponse });
} }
// Mutation failed
const errMsg = `Decline failed: HTTP ${declineResult.status}`; const errMsg = `Decline failed: HTTP ${declineResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
return res.status(declineResult.status).json(errorBody); return res.status(declineResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Decline error:', err.message); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'decline', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });
// -------------------------------------------------------------------
// POST /queue/:queueItemId/redirect // POST /queue/:queueItemId/redirect
// Redirect asset from one team to another via CARD API. router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
// -------------------------------------------------------------------
router.post('/queue/:queueItemId/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -490,7 +317,6 @@ function createCardApiRouter(db, requireAuth) {
const { queueItemId } = req.params; const { queueItemId } = req.params;
const { fromTeam, toTeam, assetId } = req.body; const { fromTeam, toTeam, assetId } = req.body;
// Validate required fields
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) { if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
return res.status(400).json({ error: 'fromTeam is required.' }); return res.status(400).json({ error: 'fromTeam is required.' });
} }
@@ -502,11 +328,11 @@ function createCardApiRouter(db, requireAuth) {
} }
try { try {
// Validate queue item const { rows } = await pool.query(
const item = await dbGet(db, 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ? AND workflow_type = ?',
[queueItemId, req.user.id, 'CARD'] [queueItemId, req.user.id, 'CARD']
); );
const item = rows[0];
if (!item) { if (!item) {
return res.status(404).json({ error: 'Queue item not found.' }); return res.status(404).json({ error: 'Queue item not found.' });
@@ -515,20 +341,10 @@ function createCardApiRouter(db, requireAuth) {
return res.status(400).json({ error: 'Only pending queue items can be executed.' }); return res.status(400).json({ error: 'Only pending queue items can be executed.' });
} }
// Step 1: Get owner record for update_token
const ownerResult = await getOwner(assetId); const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) { if (!ownerResult.ok) {
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`; const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; } try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
return res.status(ownerResult.status).json(errorBody); return res.status(ownerResult.status).json(errorBody);
@@ -540,71 +356,33 @@ function createCardApiRouter(db, requireAuth) {
if (!updateToken) { if (!updateToken) {
const errMsg = 'update_token not found in owner record.'; const errMsg = 'update_token not found in owner record.';
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null },
ipAddress: req.ip,
});
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg }); return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
} }
// Step 2: Execute redirect mutation
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
if (redirectResult.ok) { if (redirectResult.ok) {
await dbRun(db, await pool.query(
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
[queueItemId] [queueItemId]
); );
let cardResponse; let cardResponse;
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
logAudit(db, { logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip });
userId: req.user.id,
username: req.user.username,
action: 'card_redirect',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status },
ipAddress: req.ip,
});
return res.json({ success: true, cardResponse }); return res.json({ success: true, cardResponse });
} }
// Mutation failed
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`; const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
console.error('[card-api]', errMsg); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status },
ipAddress: req.ip,
});
let errorBody; let errorBody;
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
return res.status(redirectResult.status).json(errorBody); return res.status(redirectResult.status).json(errorBody);
} catch (err) { } catch (err) {
console.error('[card-api] Redirect error:', err.message); logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'card_action_failed',
entityType: 'ivanti_todo_queue',
entityId: String(queueItemId),
details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null },
ipAddress: req.ip,
});
return handleCardError(err, res); return handleCardError(err, res);
} }
}); });

File diff suppressed because it is too large Load Diff

111
backend/routes/feedback.js Normal file
View File

@@ -0,0 +1,111 @@
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
const express = require('express');
const https = require('https');
const http = require('http');
const { requireAuth } = require('../middleware/auth');
function createFeedbackRouter() {
const router = express.Router();
const GITLAB_URL = process.env.GITLAB_URL || '';
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
const GITLAB_PAT = process.env.GITLAB_PAT || '';
router.post('/', requireAuth(), async (req, res) => {
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
return res.status(503).json({ error: 'Feedback integration not configured' });
}
const { type, title, description, page } = req.body;
if (!type || !title || !description) {
return res.status(400).json({ error: 'type, title, and description are required' });
}
if (!['bug', 'feature'].includes(type)) {
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
}
const labels = type === 'bug' ? 'bug' : 'enhancement';
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
const username = req.user?.username || 'unknown';
const body = [
`**Submitted by:** ${username}`,
page ? `**Page:** ${page}` : null,
`**Type:** ${prefix}`,
'',
'---',
'',
description,
].filter(Boolean).join('\n');
const postData = JSON.stringify({
title: `[${prefix}] ${title}`,
description: body,
labels,
});
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
try {
const result = await new Promise((resolve, reject) => {
const parsed = new URL(apiUrl);
const transport = parsed.protocol === 'https:' ? https : http;
const reqOpts = {
method: 'POST',
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
headers: {
'Content-Type': 'application/json',
'PRIVATE-TOKEN': GITLAB_PAT,
'Content-Length': Buffer.byteLength(postData),
},
rejectAuthorized: false,
};
const apiReq = transport.request(reqOpts, (apiRes) => {
let data = '';
apiRes.on('data', chunk => data += chunk);
apiRes.on('end', () => {
try {
resolve({ status: apiRes.statusCode, body: JSON.parse(data) });
} catch {
resolve({ status: apiRes.statusCode, body: data });
}
});
});
apiReq.on('error', reject);
apiReq.write(postData);
apiReq.end();
});
if (result.status === 201) {
console.log(`[Feedback] Issue #${result.body.iid} created by ${username}: ${title}`);
res.json({
success: true,
issue: {
id: result.body.iid,
url: result.body.web_url,
title: result.body.title,
},
});
} else {
console.error(`[Feedback] GitLab API returned ${result.status}:`, result.body);
res.status(502).json({ error: 'GitLab API error', details: result.body });
}
} catch (err) {
console.error('[Feedback] Request failed:', err.message);
res.status(502).json({ error: 'Failed to connect to GitLab' });
}
});
return router;
}
module.exports = createFeedbackRouter;

View File

@@ -1,19 +1,12 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings // Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth } = require('../middleware/auth');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
/** /**
* Find the most severe active finding related to an archived finding. * Find the most severe active finding related to an archived finding.
*
* A match requires:
* - Exact hostname match (case-sensitive)
* - The archive title is a case-insensitive substring of the active title, or vice versa
* - The active finding ID differs from the archive's finding_id
*
* @param {Object} archive - Archive record from ivanti_finding_archives
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
* @returns {{ id: string, title: string, severity: number } | null}
*/ */
function findRelatedActive(archive, activeFindings) { function findRelatedActive(archive, activeFindings) {
const archiveTitle = (archive.finding_title || '').toLowerCase(); const archiveTitle = (archive.finding_title || '').toLowerCase();
@@ -34,21 +27,13 @@ function findRelatedActive(archive, activeFindings) {
return { id: best.id, title: best.title, severity: best.severity }; return { id: best.id, title: best.title, severity: best.severity };
} }
function createIvantiArchiveRouter(db, requireAuth) { function createIvantiArchiveRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
/** // GET / — List archive records with optional state filtering
* GET /
* List archive records with optional state filtering.
*
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
* @returns {Object} 400 - { error: string } when state param is invalid
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { state } = req.query; const { state } = req.query;
@@ -61,43 +46,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
try { try {
let query = 'SELECT * FROM ivanti_finding_archives'; let query = 'SELECT * FROM ivanti_finding_archives';
const params = []; const params = [];
let paramIndex = 1;
if (state) { if (state) {
query += ' WHERE current_state = ?'; query += ` WHERE current_state = $${paramIndex++}`;
params.push(state); params.push(state);
} }
query += ' ORDER BY last_transition_at DESC'; query += ' ORDER BY last_transition_at DESC';
const archives = await new Promise((resolve, reject) => { const { rows: archives } = await pool.query(query, params);
db.all(query, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
// Fetch and parse active findings cache for related-finding enrichment // Fetch active findings for related-finding enrichment
// In the new schema, active findings are in ivanti_findings table
let activeFindings = []; let activeFindings = [];
try { try {
const cacheRow = await new Promise((resolve, reject) => { const { rows: findingsRows } = await pool.query(
db.get( `SELECT id, title, host_name AS "hostName", severity FROM ivanti_findings WHERE state = 'open'`
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1', );
(err, row) => { activeFindings = findingsRows;
if (err) reject(err);
else resolve(row);
}
);
});
if (cacheRow && cacheRow.findings_json) {
activeFindings = JSON.parse(cacheRow.findings_json);
}
} catch (cacheErr) { } catch (cacheErr) {
console.warn('Failed to load findings cache for related-active matching:', cacheErr); console.warn('Failed to load findings for related-active matching:', cacheErr);
}
if (!Array.isArray(activeFindings)) {
activeFindings = [];
} }
// Enrich each archive record with related active finding info // Enrich each archive record with related active finding info
@@ -113,52 +82,28 @@ function createIvantiArchiveRouter(db, requireAuth) {
} }
}); });
/** // GET /stats — Summary counts by lifecycle state
* GET /stats
* Summary counts of archive records by lifecycle state.
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
*
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/stats', async (req, res) => { router.get('/stats', async (req, res) => {
try { try {
// Count archive records by state const { rows } = await pool.query(
const rows = await new Promise((resolve, reject) => { `SELECT current_state, COUNT(*) as count
db.all( FROM ivanti_finding_archives
`SELECT current_state, COUNT(*) as count GROUP BY current_state`
FROM ivanti_finding_archives );
GROUP BY current_state`,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
for (const row of rows) { for (const row of rows) {
if (stats.hasOwnProperty(row.current_state)) { if (stats.hasOwnProperty(row.current_state)) {
stats[row.current_state] = row.count; stats[row.current_state] = parseInt(row.count);
} }
} }
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records // ACTIVE = total live findings count
const cacheRow = await new Promise((resolve, reject) => { const countResult = await pool.query(
db.get( `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`
'SELECT total FROM ivanti_findings_cache WHERE id = 1', );
(err, row) => { stats.ACTIVE = parseInt(countResult.rows[0].total) || 0;
if (err) reject(err);
else resolve(row);
}
);
});
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
// so ACTIVE = live count (all findings currently present in sync results)
stats.ACTIVE = liveFindingsCount;
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED; const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
@@ -169,46 +114,27 @@ function createIvantiArchiveRouter(db, requireAuth) {
} }
}); });
/** // GET /:findingId/history — Transition history for a specific archived finding
* GET /:findingId/history
* Transition history for a specific archived finding, ordered by most recent first.
* Returns an empty transitions array if the finding has no archive record.
*
* @param {string} findingId - Ivanti finding identifier (route param)
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
* @returns {Object} 500 - { error: string } on database failure
*/
router.get('/:findingId/history', async (req, res) => { router.get('/:findingId/history', async (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
try { try {
const archive = await new Promise((resolve, reject) => { const { rows: archiveRows } = await pool.query(
db.get( 'SELECT id FROM ivanti_finding_archives WHERE finding_id = $1',
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?', [findingId]
[findingId], );
(err, row) => { const archive = archiveRows[0];
if (err) reject(err);
else resolve(row);
}
);
});
if (!archive) { if (!archive) {
return res.json({ finding_id: findingId, transitions: [] }); return res.json({ finding_id: findingId, transitions: [] });
} }
const transitions = await new Promise((resolve, reject) => { const { rows: transitions } = await pool.query(
db.all( `SELECT * FROM ivanti_archive_transitions
`SELECT * FROM ivanti_archive_transitions WHERE archive_id = $1
WHERE archive_id = ? ORDER BY transitioned_at DESC`,
ORDER BY transitioned_at DESC`, [archive.id]
[archive.id], );
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
res.json({ finding_id: findingId, transitions }); res.json({ finding_id: findingId, transitions });
} catch (err) { } catch (err) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
@@ -12,71 +13,34 @@ function isValidVendor(vendor) {
return trimmed.length > 0 && trimmed.length <= 200; return trimmed.length > 0 && trimmed.length <= 200;
} }
function createIvantiTodoQueueRouter(db, requireAuth) { function createIvantiTodoQueueRouter() {
const router = express.Router(); const router = express.Router();
/** // GET /api/ivanti/todo-queue
* GET /api/ivanti/todo-queue router.get('/', requireAuth(), async (req, res) => {
* try {
* Fetch the current user's queue items, ordered by vendor then created_at. const { rows } = await pool.query(
* `SELECT q.*
* @returns {Array<Object>} 200 - Array of queue items, each with: FROM ivanti_todo_queue q
* id, user_id, finding_id, finding_title, cves_json, ip_address, WHERE q.user_id = $1
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array) ORDER BY q.vendor ASC, q.created_at ASC`,
* @returns {Object} 500 - { error: string } on database error [req.user.id]
*/ );
router.get('/', requireAuth(db), (req, res) => { const parsed = rows.map((r) => ({
db.all( ...r,
`SELECT q.*, cves: r.cves_json ? JSON.parse(r.cves_json) : [],
o.value AS override_hostname }));
FROM ivanti_todo_queue q res.json(parsed);
LEFT JOIN ivanti_finding_overrides o } catch (err) {
ON o.finding_id = q.finding_id AND o.field = 'hostName' console.error('Error fetching todo queue:', err);
WHERE q.user_id = ? res.status(500).json({ error: 'Internal server error.' });
ORDER BY q.vendor ASC, q.created_at ASC`, }
[req.user.id],
(err, rows) => {
if (err) {
console.error('Error fetching todo queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Parse cves_json back to array; prefer overridden hostname
const parsed = rows.map((r) => ({
...r,
hostname: r.override_hostname || r.hostname,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
// Clean up the extra column from the response
parsed.forEach((r) => delete r.override_hostname);
res.json(parsed);
}
);
}); });
/** // POST /api/ivanti/todo-queue/batch
* POST /api/ivanti/todo-queue/batch router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Add multiple findings to the current user's queue in a single transaction.
*
* @body {Object[]} findings - Required array of 1200 finding objects
* @body {string} findings[].finding_id - Required, non-empty finding identifier
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
*
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
*/
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findings, workflow_type, vendor } = req.body; const { findings, workflow_type, vendor } = req.body;
// --- Validation ---
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
return res.status(400).json({ error: 'findings array must contain 1-200 items.' }); return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
} }
@@ -105,131 +69,70 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
const userId = req.user.id; const userId = req.user.id;
// --- Transactional batch insert --- const client = await pool.connect();
// Prepare all row values upfront try {
const rows = findings.map((f) => { await client.query('BEGIN');
const findingId = f.finding_id.trim();
const title = f.finding_title && typeof f.finding_title === 'string'
? f.finding_title.slice(0, 500)
: null;
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
const ipVal = f.ip_address && typeof f.ip_address === 'string'
? f.ip_address.trim().slice(0, 64)
: null;
const hostVal = f.hostname && typeof f.hostname === 'string'
? f.hostname.trim().slice(0, 255)
: null;
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
});
const insertedIds = []; const insertedIds = [];
let insertError = null; for (const f of findings) {
let remaining = rows.length; const findingId = f.finding_id.trim();
const title = f.finding_title && typeof f.finding_title === 'string'
? f.finding_title.slice(0, 500) : null;
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
const ipVal = f.ip_address && typeof f.ip_address === 'string'
? f.ip_address.trim().slice(0, 64) : null;
const hostVal = f.hostname && typeof f.hostname === 'string'
? f.hostname.trim().slice(0, 255) : null;
db.serialize(() => { const { rows } = await client.query(
db.run('BEGIN TRANSACTION');
rows.forEach((params) => {
db.run(
`INSERT INTO ivanti_todo_queue `INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
params, RETURNING id`,
function (err) { [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
if (err && !insertError) {
insertError = err;
} else if (!err) {
insertedIds.push(this.lastID);
}
remaining--;
// After all insert callbacks have fired, commit or rollback
if (remaining === 0) {
if (insertError) {
db.run('ROLLBACK', () => {
console.error('Batch insert error:', insertError);
return res.status(500).json({ error: 'Internal server error.' });
});
} else {
db.run('COMMIT', (commitErr) => {
if (commitErr) {
console.error('Batch commit error:', commitErr);
db.run('ROLLBACK', () => {});
return res.status(500).json({ error: 'Internal server error.' });
}
// Fetch all inserted rows
const placeholders = insertedIds.map(() => '?').join(',');
db.all(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id IN (${placeholders})`,
insertedIds,
(fetchErr, fetchedRows) => {
if (fetchErr) {
console.error('Error fetching inserted batch rows:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const items = (fetchedRows || []).map((r) => {
const item = {
...r,
hostname: r.override_hostname || r.hostname,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
};
delete item.override_hostname;
return item;
});
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'batch_add_to_queue',
entityType: 'ivanti_todo_queue',
entityId: null,
details: {
count: insertedIds.length,
workflow_type: workflow_type,
finding_ids: findings.map((f) => f.finding_id.trim()),
},
ipAddress: req.ip,
});
return res.status(201).json({ items });
}
);
});
}
}
}
); );
insertedIds.push(rows[0].id);
}
await client.query('COMMIT');
// Fetch all inserted rows
const { rows: fetchedRows } = await pool.query(
`SELECT * FROM ivanti_todo_queue WHERE id = ANY($1)`,
[insertedIds]
);
const items = fetchedRows.map((r) => ({
...r,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'batch_add_to_queue',
entityType: 'ivanti_todo_queue',
entityId: null,
details: {
count: insertedIds.length,
workflow_type: workflow_type,
finding_ids: findings.map((f) => f.finding_id.trim()),
},
ipAddress: req.ip,
}); });
});
return res.status(201).json({ items });
} catch (err) {
await client.query('ROLLBACK');
console.error('Batch insert error:', err);
return res.status(500).json({ error: 'Internal server error.' });
} finally {
client.release();
}
}); });
/** // POST /api/ivanti/todo-queue
* POST /api/ivanti/todo-queue router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Add a single finding to the current user's queue.
*
* @body {string} finding_id - Required, non-empty finding identifier
* @body {string} [finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [cves] - Optional array of CVE identifiers
* @body {string} [ip_address] - Optional IP address (max 64 chars)
* @body {string} [hostname] - Optional hostname (max 255 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
*
* @returns {Object} 201 - Created queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body; const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
@@ -238,7 +141,6 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
} }
// Vendor is required for FP and Archer, optional for CARD/GRANITE
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) { if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
} }
@@ -251,61 +153,30 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null; const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
const title = finding_title && typeof finding_title === 'string' const title = finding_title && typeof finding_title === 'string'
? finding_title.slice(0, 500) ? finding_title.slice(0, 500) : null;
: null;
db.run( try {
`INSERT INTO ivanti_todo_queue const { rows } = await pool.query(
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) `INSERT INTO ivanti_todo_queue
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type], VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
function (err) { RETURNING *`,
if (err) { [req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type]
console.error('Error adding to queue:', err); );
return res.status(500).json({ error: 'Internal server error.' });
} const result = {
db.get( ...rows[0],
`SELECT q.*, o.value AS override_hostname cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
FROM ivanti_todo_queue q };
LEFT JOIN ivanti_finding_overrides o res.status(201).json(result);
ON o.finding_id = q.finding_id AND o.field = 'hostName' } catch (err) {
WHERE q.id = ?`, console.error('Error adding to queue:', err);
[this.lastID], res.status(500).json({ error: 'Internal server error.' });
(err2, row) => { }
if (err2 || !row) {
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
}
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
res.status(201).json(result);
}
);
}
);
}); });
/** // PUT /api/ivanti/todo-queue/:id
* PUT /api/ivanti/todo-queue/:id router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
* @body {string} [vendor] - New vendor string (max 200 chars)
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} [status] - One of 'pending', 'complete'
*
* @returns {Object} 200 - Updated queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { vendor, workflow_type, status } = req.body; const { vendor, workflow_type, status } = req.body;
@@ -319,248 +190,160 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
return res.status(400).json({ error: 'status must be pending or complete.' }); return res.status(400).json({ error: 'status must be pending or complete.' });
} }
db.get( try {
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', const { rows: existingRows } = await pool.query(
[id, req.user.id], 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
(err, existing) => { [id, req.user.id]
if (err) { );
console.error(err); if (!existingRows[0]) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
if (!existing) {
return res.status(404).json({ error: 'Queue item not found.' });
}
const updates = [];
const params = [];
if (vendor !== undefined) {
updates.push('vendor = ?');
params.push(vendor.trim());
}
if (workflow_type !== undefined) {
updates.push('workflow_type = ?');
params.push(workflow_type);
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id, req.user.id);
db.run(
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params,
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id = ?`,
[id],
(err3, row) => {
if (err3 || !row) {
return res.json({ message: 'Queue item updated.' });
}
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
res.json(result);
}
);
}
);
} }
);
const updates = [];
const params = [];
let paramIndex = 1;
if (vendor !== undefined) {
updates.push(`vendor = $${paramIndex++}`);
params.push(vendor.trim());
}
if (workflow_type !== undefined) {
updates.push(`workflow_type = $${paramIndex++}`);
params.push(workflow_type);
}
if (status !== undefined) {
updates.push(`status = $${paramIndex++}`);
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = NOW()');
params.push(id, req.user.id);
await pool.query(
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex}`,
params
);
const { rows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = $1', [id]
);
const result = {
...rows[0],
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
};
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** // POST /api/ivanti/todo-queue/:id/redirect
* POST /api/ivanti/todo-queue/:id/redirect router.post('/:id/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Redirect a completed queue item to a different workflow type.
* Creates a new pending item copying finding data from the original.
*
* @param {string} id - Original queue item ID (URL parameter)
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE'
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE
*
* @returns {Object} 201 - Newly created queue item with parsed cves array
* @returns {Object} 400 - { error: string } on validation failure or item not complete
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { workflow_type, vendor } = req.body; const { workflow_type, vendor } = req.body;
// --- Validation ---
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
} }
if (!['CARD', 'GRANITE'].includes(workflow_type)) { if (!['CARD', 'GRANITE'].includes(workflow_type)) {
if (!isValidVendor(vendor)) { if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
} }
} }
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) { if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' }); return res.status(400).json({ error: 'vendor must be under 200 chars.' });
} }
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
// --- Fetch original item scoped to current user --- try {
db.get( const { rows: origRows } = await pool.query(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', 'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
[id, req.user.id], [id, req.user.id]
(err, original) => { );
if (err) { const original = origRows[0];
console.error('Error fetching queue item for redirect:', err); if (!original) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
if (!original) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (original.status !== 'complete') {
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
}
// --- INSERT new row copying finding data from original ---
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
function (insertErr) {
if (insertErr) {
console.error('Error inserting redirected queue item:', insertErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const newId = this.lastID;
// --- Fetch the inserted row ---
db.get(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id = ?`,
[newId],
(fetchErr, row) => {
if (fetchErr || !row) {
console.error('Error fetching redirected queue item:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'queue_item_redirected',
entityType: 'ivanti_todo_queue',
entityId: String(original.id),
details: {
original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type,
new_item_id: newId,
vendor: vendorVal,
},
ipAddress: req.ip,
});
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
return res.status(201).json(result);
}
);
}
);
} }
); if (original.status !== 'complete') {
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
}
const { rows } = await pool.query(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type]
);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'queue_item_redirected',
entityType: 'ivanti_todo_queue',
entityId: String(original.id),
details: {
original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type,
new_item_id: rows[0].id,
vendor: vendorVal,
},
ipAddress: req.ip,
});
const result = {
...rows[0],
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
};
return res.status(201).json(result);
} catch (err) {
console.error('Error redirecting queue item:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** // DELETE /api/ivanti/todo-queue/completed
* DELETE /api/ivanti/todo-queue/completed router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* try {
* Bulk-delete all completed items for the current user. const result = await pool.query(
* IMPORTANT: This route must be registered BEFORE DELETE /:id. "DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
* [req.user.id]
* @returns {Object} 200 - { message: string, deleted: number } );
* @returns {Object} 500 - { error: string } on database error res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
*/ } catch (err) {
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { console.error('Error clearing completed queue items:', err);
db.run( res.status(500).json({ error: 'Internal server error.' });
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", }
[req.user.id],
function (err) {
if (err) {
console.error('Error clearing completed queue items:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Completed items cleared.', deleted: this.changes });
}
);
}); });
/** // DELETE /api/ivanti/todo-queue/:id
* DELETE /api/ivanti/todo-queue/:id router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
*
* Delete a single queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
*
* @returns {Object} 200 - { message: string }
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get( try {
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', const { rows } = await pool.query(
[id, req.user.id], 'SELECT id FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
(err, row) => { [id, req.user.id]
if (err) { );
console.error(err); if (!rows[0]) {
return res.status(500).json({ error: 'Internal server error.' }); return res.status(404).json({ error: 'Queue item not found.' });
}
if (!row) {
return res.status(404).json({ error: 'Queue item not found.' });
}
db.run(
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Queue item deleted.' });
}
);
} }
);
await pool.query(
'DELETE FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2',
[id, req.user.id]
);
res.json({ message: 'Queue item deleted.' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
return router; return router;

View File

@@ -1,46 +1,17 @@
// Ivanti / RiskSense Workflow Routes // Ivanti / RiskSense Workflow Routes
// Data is cached in SQLite and refreshed on a daily schedule or on-demand. // Data is cached in PostgreSQL and refreshed on a daily schedule or on-demand.
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const { ivantiPost } = require('../helpers/ivantiApi'); const { ivantiPost } = require('../helpers/ivantiApi');
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ensure the sync state table exists (idempotent — safe to call on every start) // Core sync — calls Ivanti API, stores result in PostgreSQL
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function initTable(db) { async function syncWorkflows() {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
total INTEGER DEFAULT 0,
workflows_json TEXT DEFAULT '[]',
synced_at DATETIME,
sync_status TEXT DEFAULT 'never',
error_message TEXT
)
`, (err) => { if (err) return reject(err); });
db.run(`
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
VALUES (1, 0, '[]', 'never')
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
// ---------------------------------------------------------------------------
// Core sync — calls Ivanti API, stores result in SQLite
// ---------------------------------------------------------------------------
async function syncWorkflows(db) {
const apiKey = process.env.IVANTI_API_KEY; const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550'; const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const firstName = process.env.IVANTI_FIRST_NAME || ''; const firstName = process.env.IVANTI_FIRST_NAME || '';
@@ -50,12 +21,10 @@ async function syncWorkflows(db) {
if (!apiKey) { if (!apiKey) {
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync'; const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
console.warn('[Ivanti]', errMsg); console.warn('[Ivanti]', errMsg);
await new Promise((resolve) => { await pool.query(
db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]
[errMsg], resolve );
);
});
return; return;
} }
@@ -107,7 +76,6 @@ async function syncWorkflows(db) {
const data = JSON.parse(result.body); const data = JSON.parse(result.body);
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
let total = 0; let total = 0;
let workflows = []; let workflows = [];
@@ -127,95 +95,89 @@ async function syncWorkflows(db) {
total = data.length; total = data.length;
} }
await new Promise((resolve, reject) => { await pool.query(
db.run( `UPDATE ivanti_sync_state
`UPDATE ivanti_sync_state SET total=$1, workflows_json=$2, synced_at=NOW(), sync_status='success', error_message=NULL
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
WHERE id=1`, [total, JSON.stringify(workflows)]
[total, JSON.stringify(workflows)], );
(err) => { if (err) reject(err); else resolve(); }
);
});
console.log(`[Ivanti] Sync complete — ${total} workflows`); console.log(`[Ivanti] Sync complete — ${total} workflows`);
} catch (err) { } catch (err) {
const msg = err.message || 'Unknown error'; const msg = err.message || 'Unknown error';
console.error('[Ivanti] Sync failed:', msg); console.error('[Ivanti] Sync failed:', msg);
await new Promise((resolve) => { await pool.query(
db.run( `UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]
[msg], resolve );
);
});
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scheduler — runs sync immediately if >24h stale, then every 24h // Scheduler — runs sync immediately if >24h stale, then every 24h
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function scheduleSync(db) { async function scheduleSync() {
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => { try {
if (err || !row || !row.synced_at) { const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1');
syncWorkflows(db); const row = rows[0];
if (!row || !row.synced_at) {
syncWorkflows();
} else { } else {
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z'); const lastSync = new Date(row.synced_at);
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60); const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
if (hoursSince >= 24) { if (hoursSince >= 24) {
syncWorkflows(db); syncWorkflows();
} else { } else {
const hoursUntil = (24 - hoursSince).toFixed(1); const hoursUntil = (24 - hoursSince).toFixed(1);
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`); console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
} }
} }
}); } catch (err) {
console.error('[Ivanti] Schedule check failed:', err);
syncWorkflows();
}
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS); setInterval(() => syncWorkflows(), SYNC_INTERVAL_MS);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper — read current state from DB and return as JSON-ready object // Helper — read current state from DB and return as JSON-ready object
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function readState(db) { async function readState() {
return new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( 'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1'
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1', );
(err, row) => { const row = rows[0];
if (err) return reject(err); if (!row) return { total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null };
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
let workflows = []; let workflows = [];
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
resolve({ return {
total: row.total || 0, total: row.total || 0,
workflows, workflows,
synced_at: row.synced_at, synced_at: row.synced_at,
sync_status: row.sync_status, sync_status: row.sync_status,
error_message: row.error_message error_message: row.error_message
}); };
}
);
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Router // Router
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createIvantiWorkflowsRouter(db, requireAuth) { function createIvantiWorkflowsRouter() {
const router = express.Router(); const router = express.Router();
// Init table and kick off scheduler (fire-and-forget on startup) // Kick off scheduler (fire-and-forget on startup)
initTable(db) scheduleSync().catch((err) => console.error('[Ivanti] Init failed:', err));
.then(() => scheduleSync(db))
.catch((err) => console.error('[Ivanti] Init failed:', err));
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
// GET / — return cached data (fast, no external call) // GET / — return cached data (fast, no external call)
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
res.json(await readState(db)); res.json(await readState());
} catch { } catch {
res.status(500).json({ error: 'Database error reading sync state' }); res.status(500).json({ error: 'Database error reading sync state' });
} }
@@ -223,9 +185,9 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
// POST /sync — trigger an immediate sync, await completion, return fresh state // POST /sync — trigger an immediate sync, await completion, return fresh state
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db); await syncWorkflows();
try { try {
res.json(await readState(db)); res.json(await readState());
} catch { } catch {
res.status(500).json({ error: 'Sync ran but could not read updated state' }); res.status(500).json({ error: 'Sync ran but could not read updated state' });
} }

View File

@@ -11,6 +11,7 @@
// - Rate limits enforced client-side (1440/day, 60/min burst) // - Rate limits enforced client-side (1440/day, 60/min burst)
const express = require('express'); const express = require('express');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const jiraApi = require('../helpers/jiraApi'); const jiraApi = require('../helpers/jiraApi');
@@ -27,24 +28,14 @@ function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
} }
function createJiraTicketsRouter(db) { function createJiraTicketsRouter() {
const router = express.Router(); const router = express.Router();
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Jira API integration endpoints // Jira API integration endpoints
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
* GET /api/jira/connection-test
*
* Verify Jira credentials and connectivity by testing the configured
* Jira API connection. Admin only.
*
* @returns {object} 200 - { connected: true, user: { name, ... } }
* @returns {object} 502 - { connected: false, status, error } | { connected: false, error }
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.get('/connection-test', requireAuth(db), requireGroup('Admin'), async (req, res) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' }); return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
} }
@@ -52,7 +43,7 @@ function createJiraTicketsRouter(db) {
try { try {
const result = await jiraApi.testConnection(); const result = await jiraApi.testConnection();
if (result.ok) { if (result.ok) {
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_connection_test', action: 'jira_connection_test',
@@ -69,32 +60,11 @@ function createJiraTicketsRouter(db) {
} }
}); });
/** router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
* GET /api/jira/rate-limit
*
* Return current Jira API rate limit usage. Admin only.
*
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
*/
router.get('/rate-limit', requireAuth(db), requireGroup('Admin'), (req, res) => {
res.json(jiraApi.getRateLimitStatus()); res.json(jiraApi.getRateLimitStatus());
}); });
/** router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
* GET /api/jira/lookup/:issueKey
*
* Fetch a single issue from Jira by its issue key (e.g., PROJECT-123).
* Uses explicit `?fields=` parameter per Charter Jira REST API requirement.
*
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
* @returns {object} 400 - { error } when issue key format is invalid
* @returns {object} 404 - { error } when issue not found in Jira
* @returns {object} 429 - { error } when Jira rate limit exceeded
* @returns {object} 502 - { error, details } on Jira API error
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.get('/lookup/:issueKey', requireAuth(db), async (req, res) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
@@ -132,90 +102,7 @@ function createJiraTicketsRouter(db) {
} }
}); });
/** router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* POST /api/jira/search
*
* Search Jira issues using a JQL query. Results are capped at 1000 per page.
* Charter compliance: JQL must include project+updated, assignee+updated,
* or status+updated. Fields are always specified explicitly.
*
* @body {string} jql - JQL query string (required, max 2000 chars)
* @body {number} [startAt] - Pagination offset
* @body {number} [maxResults] - Page size (max 1000)
* @body {string[]} [fields] - Explicit field list for the Jira response
* @returns {object} 200 - { total, startAt, maxResults, issues: [{ key, summary, status, assignee, priority, issuetype, created, updated }] }
* @returns {object} 400 - { error } when JQL is missing or too long
* @returns {object} 429 - { error } when Jira rate limit exceeded
* @returns {object} 502 - { error, details } on Jira search failure
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.post('/search', requireAuth(db), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
}
const { jql, startAt, maxResults, fields } = req.body;
if (!jql || typeof jql !== 'string' || jql.trim().length === 0) {
return res.status(400).json({ error: 'JQL query is required.' });
}
if (jql.length > 2000) {
return res.status(400).json({ error: 'JQL query too long (max 2000 chars).' });
}
try {
const result = await jiraApi.searchIssues(jql, {
startAt,
maxResults: Math.min(maxResults || 1000, 1000),
fields: fields || undefined
});
if (result.ok) {
const data = result.data;
return res.json({
total: data.total,
startAt: data.startAt,
maxResults: data.maxResults,
issues: (data.issues || []).map(issue => ({
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status ? issue.fields.status.name : null,
assignee: issue.fields.assignee ? issue.fields.assignee.displayName : null,
priority: issue.fields.priority ? issue.fields.priority.name : null,
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null,
created: issue.fields.created,
updated: issue.fields.updated
}))
});
}
if (result.rateLimited) {
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
}
return res.status(502).json({ error: 'Jira search failed.', details: result.body });
} catch (err) {
return res.status(502).json({ error: err.message });
}
});
/**
* POST /api/jira/create-in-jira
*
* Create a new issue in Jira via the REST API and insert a linked local
* record in the `jira_tickets` table. Requires Admin or Standard_User group.
* Subject to 2s write delay enforced by jiraApi.
*
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
* @body {string} vendor - Vendor name (required, max 200 chars)
* @body {string} summary - Issue summary (required, max 255 chars)
* @body {string} [description] - Issue description
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
* @returns {object} 201 - { id, ticket_key, jira_url, message }
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local save failed
* @returns {object} 400 - { error } on validation failure
* @returns {object} 429 - { error } when Jira rate limit exceeded
* @returns {object} 502 - { error, details } on Jira API failure
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.post('/create-in-jira', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
@@ -264,191 +151,150 @@ function createJiraTicketsRouter(db) {
? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`) ? jiraIssue.self.replace(/\/rest\/api\/2\/issue\/.*/, `/browse/${ticketKey}`)
: null; : null;
db.run( try {
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)`, `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id], VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
function(err) { RETURNING id`,
if (err) { [cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
console.error('Error saving local Jira ticket record:', err); );
return res.status(207).json({
warning: 'Issue created in Jira but local record failed to save.',
jira_key: ticketKey,
jira_url: jiraUrl,
error: err.message
});
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_create_via_api', action: 'jira_ticket_create_via_api',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: this.lastID.toString(), entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey }, details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
ticket_key: ticketKey, ticket_key: ticketKey,
jira_url: jiraUrl, jira_url: jiraUrl,
message: 'Jira issue created and linked successfully' message: 'Jira issue created and linked successfully'
}); });
} } catch (dbErr) {
); console.error('Error saving local Jira ticket record:', dbErr);
return res.status(207).json({
warning: 'Issue created in Jira but local record failed to save.',
jira_key: ticketKey,
jira_url: jiraUrl,
error: dbErr.message
});
}
} catch (err) { } catch (err) {
return res.status(502).json({ error: err.message }); return res.status(502).json({ error: err.message });
} }
}); });
/** router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
* POST /api/jira/sync-all
*
* Bulk-sync all local tickets that have a Jira key by fetching their
* latest status from Jira. Uses a single JQL bulk search per batch
* instead of one GET per ticket (Charter-compliant). Stops early if
* the rate limit budget is running low. Admin only.
*
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
* @returns {object} 500 - { error } on database error
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.post('/sync-all', requireAuth(db), requireGroup('Admin'), async (req, res) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
db.all( try {
"SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''", const { rows: tickets } = await pool.query(
[], "SELECT * FROM jira_tickets WHERE ticket_key IS NOT NULL AND ticket_key != ''"
async (err, tickets) => { );
if (err) {
console.error(err); if (tickets.length === 0) {
return res.status(500).json({ error: 'Internal server error.' }); return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] });
}
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] };
const BATCH_SIZE = 100;
const batches = [];
for (let i = 0; i < tickets.length; i += BATCH_SIZE) {
batches.push(tickets.slice(i, i + BATCH_SIZE));
}
for (const batch of batches) {
const rateStatus = jiraApi.getRateLimitStatus();
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
results.skipped += remaining;
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
break;
} }
if (tickets.length === 0) { const keys = batch.map(t => t.ticket_key);
return res.json({ synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }); try {
} const result = await jiraApi.searchIssuesByKeys(keys);
if (!result.ok) {
const results = { synced: 0, failed: 0, skipped: 0, unchanged: 0, errors: [] }; if (result.rateLimited) {
results.skipped += batch.length;
// Batch keys into groups of 100 for JQL (avoid overly long queries) results.errors.push('Jira rate limit hit during sync.');
const BATCH_SIZE = 100; break;
const batches = []; }
for (let i = 0; i < tickets.length; i += BATCH_SIZE) { results.failed += batch.length;
batches.push(tickets.slice(i, i + BATCH_SIZE)); results.errors.push(`Batch search failed: HTTP ${result.status}`);
} continue;
for (const batch of batches) {
// Check rate limit before each batch
const rateStatus = jiraApi.getRateLimitStatus();
if (rateStatus.burst.remaining <= 5 || rateStatus.daily.remaining <= 10) {
const remaining = tickets.length - results.synced - results.failed - results.unchanged;
results.skipped += remaining;
results.errors.push('Rate limit approaching — stopped sync early to preserve budget.');
break;
} }
const keys = batch.map(t => t.ticket_key); const issueMap = {};
try { for (const issue of (result.data.issues || [])) {
// Bulk JQL search — Charter-compliant, single request per batch issueMap[issue.key] = issue;
const result = await jiraApi.searchIssuesByKeys(keys); }
if (!result.ok) {
if (result.rateLimited) { for (const ticket of batch) {
results.skipped += batch.length; const issue = issueMap[ticket.ticket_key];
results.errors.push('Jira rate limit hit during sync.'); if (!issue) {
break; results.unchanged++;
}
results.failed += batch.length;
results.errors.push(`Batch search failed: HTTP ${result.status}`);
continue; continue;
} }
// Build a map of key → Jira issue data const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
const issueMap = {}; const jiraSummary = issue.fields.summary || ticket.summary;
for (const issue of (result.data.issues || [])) { const localStatus = mapJiraStatusToLocal(jiraStatus);
issueMap[issue.key] = issue;
try {
await pool.query(
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
[jiraSummary, localStatus, jiraStatus, ticket.id]
);
results.synced++;
} catch (dbErr) {
results.failed++;
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
} }
// Update each local ticket from the search results
for (const ticket of batch) {
const issue = issueMap[ticket.ticket_key];
if (!issue) {
// Issue not returned — either not updated in last 24h or not found
results.unchanged++;
continue;
}
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
const jiraSummary = issue.fields.summary || ticket.summary;
const localStatus = mapJiraStatusToLocal(jiraStatus);
try {
await new Promise((resolve, reject) => {
db.run(
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[jiraSummary, localStatus, jiraStatus, ticket.id],
(updateErr) => updateErr ? reject(updateErr) : resolve()
);
});
results.synced++;
} catch (dbErr) {
results.failed++;
results.errors.push(`${ticket.ticket_key}: DB update failed — ${dbErr.message}`);
}
}
} catch (searchErr) {
results.failed += batch.length;
results.errors.push(`Batch search error: ${searchErr.message}`);
} }
} catch (searchErr) {
results.failed += batch.length;
results.errors.push(`Batch search error: ${searchErr.message}`);
} }
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_sync_all',
entityType: 'jira_integration',
entityId: null,
details: results,
ipAddress: req.ip
});
res.json(results);
} }
);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'jira_sync_all',
entityType: 'jira_integration',
entityId: null,
details: results,
ipAddress: req.ip
});
res.json(results);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* POST /api/jira/:id/sync
*
* Sync a single local ticket with Jira by fetching the latest status,
* summary, and mapping the Jira status to the local three-state model.
* Uses getIssue with explicit fields (Charter-compliant GET).
* Requires Admin or Standard_User group.
*
* @param {number} id - Local jira_tickets row ID (path parameter)
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
* @returns {object} 400 - { error } when ticket has no Jira key
* @returns {object} 404 - { error } when local ticket not found
* @returns {object} 429 - { error } when Jira rate limit exceeded
* @returns {object} 500 - { error } on database error
* @returns {object} 502 - { error, details } on Jira API failure
* @returns {object} 503 - { error } when Jira API is not configured
*/
router.post('/:id/sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!jiraApi.isConfigured) { if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' }); return res.status(503).json({ error: 'Jira API is not configured.' });
} }
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], async (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
@@ -456,117 +302,83 @@ function createJiraTicketsRouter(db) {
return res.status(400).json({ error: 'Ticket has no Jira key to sync.' }); return res.status(400).json({ error: 'Ticket has no Jira key to sync.' });
} }
try { const result = await jiraApi.getIssue(ticket.ticket_key);
const result = await jiraApi.getIssue(ticket.ticket_key); if (!result.ok) {
if (!result.ok) { if (result.rateLimited) {
if (result.rateLimited) { return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
}
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
} }
return res.status(502).json({ error: 'Failed to fetch issue from Jira.', details: result.body });
const issue = result.data;
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
const jiraSummary = issue.fields.summary || ticket.summary;
const localStatus = mapJiraStatusToLocal(jiraStatus);
db.run(
`UPDATE jira_tickets SET summary = ?, status = ?, jira_status = ?, last_synced_at = datetime('now'), updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[jiraSummary, localStatus, jiraStatus, id],
function(updateErr) {
if (updateErr) {
console.error('Error updating synced ticket:', updateErr);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_sync',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
ipAddress: req.ip
});
res.json({
message: 'Ticket synced with Jira',
ticket_key: ticket.ticket_key,
jira_status: jiraStatus,
local_status: localStatus,
summary: jiraSummary
});
}
);
} catch (err) {
return res.status(502).json({ error: err.message });
} }
});
const issue = result.data;
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
const jiraSummary = issue.fields.summary || ticket.summary;
const localStatus = mapJiraStatusToLocal(jiraStatus);
await pool.query(
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
[jiraSummary, localStatus, jiraStatus, id]
);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_sync',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, jira_status: jiraStatus, local_status: localStatus },
ipAddress: req.ip
});
res.json({
message: 'Ticket synced with Jira',
ticket_key: ticket.ticket_key,
jira_status: jiraStatus,
local_status: localStatus,
summary: jiraSummary
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
}); });
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Local CRUD endpoints (migrated from server.js) // Local CRUD endpoints
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** router.get('/', requireAuth(), async (req, res) => {
* GET /api/jira
*
* List all local JIRA ticket records with optional filters.
* Results are ordered by `created_at` descending.
*
* @query {string} [cve_id] - Filter by CVE ID
* @query {string} [vendor] - Filter by vendor name
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
* @returns {object[]} 200 - Array of jira_tickets rows
* @returns {object} 500 - { error } on database error
*/
router.get('/', requireAuth(db), (req, res) => {
const { cve_id, vendor, status } = req.query; const { cve_id, vendor, status } = req.query;
let query = 'SELECT * FROM jira_tickets WHERE 1=1'; let query = 'SELECT * FROM jira_tickets WHERE 1=1';
const params = []; const params = [];
let paramIndex = 1;
if (cve_id) { if (cve_id) {
query += ' AND cve_id = ?'; query += ` AND cve_id = $${paramIndex++}`;
params.push(cve_id); params.push(cve_id);
} }
if (vendor) { if (vendor) {
query += ' AND vendor = ?'; query += ` AND vendor = $${paramIndex++}`;
params.push(vendor); params.push(vendor);
} }
if (status) { if (status) {
query += ' AND status = ?'; query += ` AND status = $${paramIndex++}`;
params.push(status); params.push(status);
} }
query += ' ORDER BY created_at DESC'; query += ' ORDER BY created_at DESC';
db.all(query, params, (err, rows) => { try {
if (err) { const { rows } = await pool.query(query, params);
console.error('Error fetching JIRA tickets:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json(rows); res.json(rows);
}); } catch (err) {
console.error('Error fetching JIRA tickets:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* POST /api/jira
*
* Create a local JIRA ticket record (manual entry, no Jira API call).
* Requires Admin or Standard_User group.
*
* @body {string} cve_id - CVE identifier (required, format: CVE-YYYY-NNNNN)
* @body {string} vendor - Vendor name (required, max 200 chars)
* @body {string} ticket_key - Jira issue key (required, max 50 chars)
* @body {string} [url] - URL to the Jira issue (max 500 chars)
* @body {string} [summary] - Ticket summary (max 500 chars)
* @body {string} [status] - Ticket status: Open, In Progress, or Closed (defaults to Open)
* @returns {object} 201 - { id, message }
* @returns {object} 400 - { error } on validation failure
* @returns {object} 500 - { error } on database error
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body; const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
if (!cve_id || !isValidCveId(cve_id)) { if (!cve_id || !isValidCveId(cve_id)) {
@@ -590,51 +402,35 @@ function createJiraTicketsRouter(db) {
const ticketStatus = status || 'Open'; const ticketStatus = status || 'Open';
db.run( try {
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by) const { rows } = await pool.query(
VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], VALUES ($1, $2, $3, $4, $5, $6, $7)
function(err) { RETURNING id`,
if (err) { [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
console.error('Error creating JIRA ticket:', err); );
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_create', action: 'jira_ticket_create',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: this.lastID.toString(), entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key, status: ticketStatus }, details: { cve_id, vendor, ticket_key, status: ticketStatus },
ipAddress: req.ip ipAddress: req.ip
}); });
res.status(201).json({ res.status(201).json({
id: this.lastID, id: rows[0].id,
message: 'JIRA ticket created successfully' message: 'JIRA ticket created successfully'
}); });
} } catch (err) {
); console.error('Error creating JIRA ticket:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* PUT /api/jira/:id
*
* Update a local JIRA ticket record. Only provided fields are updated.
* Requires Admin or Standard_User group.
*
* @param {number} id - Local jira_tickets row ID (path parameter)
* @body {string} [ticket_key] - Jira issue key (max 50 chars)
* @body {string} [url] - URL to the Jira issue (max 500 chars, or null)
* @body {string} [summary] - Ticket summary (max 500 chars, or null)
* @body {string} [status] - Ticket status: Open, In Progress, or Closed
* @returns {object} 200 - { message, changes }
* @returns {object} 400 - { error } on validation failure or no fields provided
* @returns {object} 404 - { error } when ticket not found
* @returns {object} 500 - { error } on database error
*/
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { ticket_key, url, summary, status } = req.body; const { ticket_key, url, summary, status } = req.body;
@@ -653,70 +449,56 @@ function createJiraTicketsRouter(db) {
const fields = []; const fields = [];
const values = []; const values = [];
let paramIndex = 1;
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); } if (ticket_key !== undefined) { fields.push(`ticket_key = $${paramIndex++}`); values.push(ticket_key.trim()); }
if (url !== undefined) { fields.push('url = ?'); values.push(url); } if (url !== undefined) { fields.push(`url = $${paramIndex++}`); values.push(url); }
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); } if (summary !== undefined) { fields.push(`summary = $${paramIndex++}`); values.push(summary); }
if (status !== undefined) { fields.push('status = ?'); values.push(status); } if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); }
if (fields.length === 0) { if (fields.length === 0) {
return res.status(400).json({ error: 'No fields to update.' }); return res.status(400).json({ error: 'No fields to update.' });
} }
fields.push('updated_at = CURRENT_TIMESTAMP'); fields.push('updated_at = NOW()');
values.push(id); values.push(id);
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const existing = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) { const result = await pool.query(
if (updateErr) { `UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
console.error('Error updating JIRA ticket:', updateErr); values
return res.status(500).json({ error: 'Internal server error.' }); );
}
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'jira_ticket_update', action: 'jira_ticket_update',
entityType: 'jira_ticket', entityType: 'jira_ticket',
entityId: id, entityId: id,
details: { before: existing, changes: req.body }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
});
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
}); });
});
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
} catch (err) {
console.error('Error updating JIRA ticket:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
/** router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* DELETE /api/jira/:id
*
* Delete a local JIRA ticket record. Admins bypass all restrictions.
* Standard_User can only delete tickets they created, and cannot delete
* tickets linked to active compliance items.
*
* @param {number} id - Local jira_tickets row ID (path parameter)
* @returns {object} 200 - { message }
* @returns {object} 403 - { error } when ownership check fails or ticket is linked to compliance
* @returns {object} 404 - { error } when ticket not found
* @returns {object} 500 - { error } on database error
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => { try {
if (err) { const { rows } = await pool.query('SELECT * FROM jira_tickets WHERE id = $1', [id]);
console.error(err); const ticket = rows[0];
return res.status(500).json({ error: 'Internal server error.' });
}
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: 'JIRA ticket not found.' }); return res.status(404).json({ error: 'JIRA ticket not found.' });
} }
@@ -733,54 +515,48 @@ function createJiraTicketsRouter(db) {
// Standard_User: compliance linkage check // Standard_User: compliance linkage check
const ticketKey = ticket.ticket_key; const ticketKey = ticket.ticket_key;
db.all( try {
`SELECT ci.id, ci.extra_json const { rows: compLinks } = await pool.query(
FROM compliance_items ci `SELECT ci.id, ci.extra_json
JOIN compliance_uploads cu ON ci.upload_id = cu.id FROM compliance_items ci
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`, JOIN compliance_uploads cu ON ci.upload_id = cu.id
[`%${ticketKey}%`], WHERE ci.status = 'active' AND ci.extra_json ILIKE $1`,
(compErr, compLinks) => { [`%${ticketKey}%`]
if (compErr && compErr.message && compErr.message.includes('no such table')) { );
compLinks = [];
} else if (compErr) {
console.error(compErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const isLinked = (compLinks || []).some(cl => { const isLinked = (compLinks || []).some(cl => {
const json = cl.extra_json || ''; const json = cl.extra_json || '';
return json.includes(ticketKey); return json.includes(ticketKey);
});
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
return performJiraDelete();
}
);
function performJiraDelete() {
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
if (deleteErr) {
console.error('Error deleting JIRA ticket:', deleteErr);
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket deleted successfully' });
}); });
if (isLinked) {
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
}
} catch (compErr) {
if (!compErr.message.includes('does not exist')) throw compErr;
} }
});
return performJiraDelete();
async function performJiraDelete() {
await pool.query('DELETE FROM jira_tickets WHERE id = $1', [id]);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'jira_ticket_delete',
entityType: 'jira_ticket',
entityId: id,
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
ipAddress: req.ip
});
res.json({ message: 'JIRA ticket deleted successfully' });
}
} catch (err) {
console.error('Error deleting JIRA ticket:', err);
res.status(500).json({ error: 'Internal server error.' });
}
}); });
return router; return router;
@@ -790,10 +566,6 @@ function createJiraTicketsRouter(db) {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Map a Jira workflow status name to the local three-state model.
* Jira statuses vary by project workflow, so this uses broad categories.
*/
function mapJiraStatusToLocal(jiraStatus) { function mapJiraStatusToLocal(jiraStatus) {
if (!jiraStatus) return 'Open'; if (!jiraStatus) return 'Open';
const lower = jiraStatus.toLowerCase(); const lower = jiraStatus.toLowerCase();

View File

@@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
function createKnowledgeBaseRouter(db, upload) { function createKnowledgeBaseRouter(upload) {
const router = express.Router(); const router = express.Router();
// Helper to sanitize filename // Helper to sanitize filename
@@ -39,20 +40,8 @@ function createKnowledgeBaseRouter(db, upload) {
return ALLOWED_EXTENSIONS.has(ext); return ALLOWED_EXTENSIONS.has(ext);
} }
/** // POST /api/knowledge-base/upload
* POST /api/knowledge-base/upload router.post('/upload', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
* Upload a new knowledge base document.
*
* @body {string} title - Article title (required)
* @body {string} [description] - Article description
* @body {string} [category] - Article category (defaults to 'General')
* @body {File} file - The document file to upload (multipart/form-data)
*
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
* @response 400 - { error: string } - Missing title, no file, or invalid file type
* @response 500 - { error: string } - Database or filesystem error
*/
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => { upload.single('file')(req, res, (err) => {
if (err) { if (err) {
console.error('[KB Upload] Multer error:', err); console.error('[KB Upload] Multer error:', err);
@@ -70,7 +59,6 @@ function createKnowledgeBaseRouter(db, upload) {
const uploadedFile = req.file; const uploadedFile = req.file;
const { title, description, category } = req.body; const { title, description, category } = req.body;
// Validate required fields
if (!title || !title.trim()) { if (!title || !title.trim()) {
console.error('[KB Upload] Error: Title is missing'); console.error('[KB Upload] Error: Title is missing');
if (uploadedFile) fs.unlinkSync(uploadedFile.path); if (uploadedFile) fs.unlinkSync(uploadedFile.path);
@@ -81,7 +69,6 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(400).json({ error: 'No file uploaded' }); return res.status(400).json({ error: 'No file uploaded' });
} }
// Validate file type
if (!isValidFileType(uploadedFile.originalname)) { if (!isValidFileType(uploadedFile.originalname)) {
fs.unlinkSync(uploadedFile.path); fs.unlinkSync(uploadedFile.path);
return res.status(400).json({ error: 'File type not allowed' }); return res.status(400).json({ error: 'File type not allowed' });
@@ -96,172 +83,121 @@ function createKnowledgeBaseRouter(db, upload) {
const filePath = path.join(kbDir, filename); const filePath = path.join(kbDir, filename);
try { try {
// Keep file in temp location until DB insert succeeds
// Check if slug already exists // Check if slug already exists
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => { const { rows: existingRows } = await pool.query(
if (err) { 'SELECT id FROM knowledge_base WHERE slug = $1', [slug]
fs.unlinkSync(uploadedFile.path); );
console.error('Error checking slug:', err);
return res.status(500).json({ error: 'Database error' }); const finalSlug = existingRows.length > 0 ? `${slug}-${timestamp}` : slug;
// Insert new knowledge base entry
const { rows } = await pool.query(
`INSERT INTO knowledge_base (
title, slug, description, category, file_path, file_name,
file_type, file_size, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
title.trim(),
finalSlug,
description || null,
category || 'General',
filePath,
sanitizedName,
uploadedFile.mimetype,
uploadedFile.size,
req.user.id
]
);
// DB insert succeeded — now move file to permanent location
try {
if (!fs.existsSync(kbDir)) {
fs.mkdirSync(kbDir, { recursive: true });
} }
fs.renameSync(uploadedFile.path, filePath);
} catch (moveErr) {
console.error('Error moving file to permanent location:', moveErr);
}
// If slug exists, append timestamp to make it unique logAudit({
const finalSlug = row ? `${slug}-${timestamp}` : slug; userId: req.user.id,
username: req.user.username,
action: 'CREATE_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(rows[0].id),
details: { title: title.trim(), filename: sanitizedName },
ipAddress: req.ip
});
// Insert new knowledge base entry res.json({
const insertSql = ` success: true,
INSERT INTO knowledge_base ( id: rows[0].id,
title, slug, description, category, file_path, file_name, title: title.trim(),
file_type, file_size, created_by slug: finalSlug,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) category: category || 'General'
`;
db.run(
insertSql,
[
title.trim(),
finalSlug,
description || null,
category || 'General',
filePath,
sanitizedName,
uploadedFile.mimetype,
uploadedFile.size,
req.user.id
],
function (err) {
if (err) {
fs.unlinkSync(uploadedFile.path);
console.error('Error inserting knowledge base entry:', err);
return res.status(500).json({ error: 'Failed to save document metadata' });
}
// DB insert succeeded — now move file to permanent location
try {
if (!fs.existsSync(kbDir)) {
fs.mkdirSync(kbDir, { recursive: true });
}
fs.renameSync(uploadedFile.path, filePath);
} catch (moveErr) {
console.error('Error moving file to permanent location:', moveErr);
// File is orphaned in temp but DB record exists — log and continue
}
// Log audit entry
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'CREATE_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(this.lastID),
details: { title: title.trim(), filename: sanitizedName },
ipAddress: req.ip
});
res.json({
success: true,
id: this.lastID,
title: title.trim(),
slug: finalSlug,
category: category || 'General'
});
}
);
}); });
} catch (error) { } catch (error) {
// Clean up temp file on error
if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path); if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
console.error('Error uploading knowledge base document:', error); console.error('Error uploading knowledge base document:', error);
res.status(500).json({ error: error.message || 'Failed to upload document' }); res.status(500).json({ error: error.message || 'Failed to upload document' });
} }
}); });
/** // GET /api/knowledge-base
* GET /api/knowledge-base router.get('/', requireAuth(), async (req, res) => {
* List all knowledge base articles. try {
* const { rows } = await pool.query(`
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }] SELECT
* @response 500 - { error: string } kb.id, kb.title, kb.slug, kb.description, kb.category,
*/ kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
router.get('/', requireAuth(db), (req, res) => { u.username as created_by_username
const sql = ` FROM knowledge_base kb
SELECT LEFT JOIN users u ON kb.created_by = u.id
kb.id, kb.title, kb.slug, kb.description, kb.category, ORDER BY kb.created_at DESC
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, `);
u.username as created_by_username
FROM knowledge_base kb
LEFT JOIN users u ON kb.created_by = u.id
ORDER BY kb.created_at DESC
`;
db.all(sql, [], (err, rows) => {
if (err) {
console.error('Error fetching knowledge base articles:', err);
return res.status(500).json({ error: 'Failed to fetch articles' });
}
res.json(rows); res.json(rows);
}); } catch (err) {
console.error('Error fetching knowledge base articles:', err);
res.status(500).json({ error: 'Failed to fetch articles' });
}
}); });
/** // GET /api/knowledge-base/:id
* GET /api/knowledge-base/:id router.get('/:id', requireAuth(), async (req, res) => {
* Get a single article's details by ID.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.get('/:id', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = ` try {
SELECT const { rows } = await pool.query(`
kb.id, kb.title, kb.slug, kb.description, kb.category, SELECT
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, kb.id, kb.title, kb.slug, kb.description, kb.category,
u.username as created_by_username kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
FROM knowledge_base kb u.username as created_by_username
LEFT JOIN users u ON kb.created_by = u.id FROM knowledge_base kb
WHERE kb.id = ? LEFT JOIN users u ON kb.created_by = u.id
`; WHERE kb.id = $1
`, [id]);
db.get(sql, [id], (err, row) => { if (!rows[0]) {
if (err) {
console.error('Error fetching article:', err);
return res.status(500).json({ error: 'Failed to fetch article' });
}
if (!row) {
return res.status(404).json({ error: 'Article not found' }); return res.status(404).json({ error: 'Article not found' });
} }
res.json(row); res.json(rows[0]);
}); } catch (err) {
console.error('Error fetching article:', err);
res.status(500).json({ error: 'Failed to fetch article' });
}
}); });
/** // GET /api/knowledge-base/:id/content
* GET /api/knowledge-base/:id/content router.get('/:id/content', requireAuth(), async (req, res) => {
* Get document content for inline display. Returns the raw file with appropriate
* Content-Type headers. Markdown and text files are served as text/plain.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/content', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching document:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch document' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Document not found' }); return res.status(404).json({ error: 'Document not found' });
@@ -271,8 +207,7 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(404).json({ error: 'File not found on disk' }); return res.status(404).json({ error: 'File not found on disk' });
} }
// Log audit entry logAudit({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'VIEW_KB_ARTICLE', action: 'VIEW_KB_ARTICLE',
@@ -282,10 +217,7 @@ function createKnowledgeBaseRouter(db, upload) {
ipAddress: req.ip ipAddress: req.ip
}); });
// Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream'; let contentType = row.file_type || 'application/octet-stream';
// For markdown files, send as plain text so frontend can parse it
if (row.file_name.endsWith('.md')) { if (row.file_name.endsWith('.md')) {
contentType = 'text/plain; charset=utf-8'; contentType = 'text/plain; charset=utf-8';
} else if (row.file_name.endsWith('.txt')) { } else if (row.file_name.endsWith('.txt')) {
@@ -294,36 +226,26 @@ function createKnowledgeBaseRouter(db, upload) {
const safeFileName = row.file_name.replace(/["\r\n\\]/g, ''); const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
// Use inline instead of attachment to allow browser to display
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`); res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
// Allow iframe embedding from frontend origin
res.removeHeader('X-Frame-Options'); res.removeHeader('X-Frame-Options');
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000'; const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`); res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
res.sendFile(row.file_path); res.sendFile(row.file_path);
}); } catch (err) {
console.error('Error fetching document:', err);
res.status(500).json({ error: 'Failed to fetch document' });
}
}); });
/** // GET /api/knowledge-base/:id/download
* GET /api/knowledge-base/:id/download router.get('/:id/download', requireAuth(), async (req, res) => {
* Download a knowledge base document as an attachment.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - File download with Content-Disposition: attachment header
* @response 404 - { error: string } - Article or file not found
* @response 500 - { error: string }
*/
router.get('/:id/download', requireAuth(db), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching document:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch document' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Document not found' }); return res.status(404).json({ error: 'Document not found' });
@@ -333,8 +255,7 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(404).json({ error: 'File not found on disk' }); return res.status(404).json({ error: 'File not found on disk' });
} }
// Log audit entry logAudit({
logAudit(db, {
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'DOWNLOAD_KB_ARTICLE', action: 'DOWNLOAD_KB_ARTICLE',
@@ -348,31 +269,21 @@ function createKnowledgeBaseRouter(db, upload) {
res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`); res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
res.sendFile(row.file_path); res.sendFile(row.file_path);
}); } catch (err) {
console.error('Error fetching document:', err);
res.status(500).json({ error: 'Failed to fetch document' });
}
}); });
/** // DELETE /api/knowledge-base/:id
* DELETE /api/knowledge-base/:id router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
* Delete a knowledge base article and its associated file.
* Standard_User can only delete articles they created. Admin can delete any article.
*
* @param {string} id - Article ID (route parameter)
*
* @response 200 - { success: true }
* @response 403 - { error: string } - Ownership check failed for Standard_User
* @response 404 - { error: 'Article not found' }
* @response 500 - { error: string }
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?'; try {
const { rows } = await pool.query(
db.get(sql, [id], (err, row) => { 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = $1', [id]
if (err) { );
console.error('Error fetching article for deletion:', err); const row = rows[0];
return res.status(500).json({ error: 'Failed to fetch article' });
}
if (!row) { if (!row) {
return res.status(404).json({ error: 'Article not found' }); return res.status(404).json({ error: 'Article not found' });
@@ -383,32 +294,28 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(403).json({ error: 'You can only delete resources you created' }); return res.status(403).json({ error: 'You can only delete resources you created' });
} }
// Delete database record await pool.query('DELETE FROM knowledge_base WHERE id = $1', [id]);
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
if (err) {
console.error('Error deleting article:', err);
return res.status(500).json({ error: 'Failed to delete article' });
}
// Delete file // Delete file
if (fs.existsSync(row.file_path)) { if (fs.existsSync(row.file_path)) {
fs.unlinkSync(row.file_path); fs.unlinkSync(row.file_path);
} }
// Log audit entry logAudit({
logAudit(db, { userId: req.user.id,
userId: req.user.id, username: req.user.username,
username: req.user.username, action: 'DELETE_KB_ARTICLE',
action: 'DELETE_KB_ARTICLE', entityType: 'knowledge_base',
entityType: 'knowledge_base', entityId: String(id),
entityId: String(id), details: { title: row.title },
details: { title: row.title }, ipAddress: req.ip
ipAddress: req.ip
});
res.json({ success: true });
}); });
});
res.json({ success: true });
} catch (err) {
console.error('Error deleting article:', err);
res.status(500).json({ error: 'Failed to delete article' });
}
}); });
return router; return router;

View File

@@ -1,13 +1,14 @@
// NVD CVE Lookup Routes // NVD CVE Lookup Routes
const express = require('express'); const express = require('express');
const { requireAuth } = require('../middleware/auth');
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/; const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
function createNvdLookupRouter(db, requireAuth) { function createNvdLookupRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication
router.use(requireAuth(db)); router.use(requireAuth());
// Lookup CVE details from NVD API 2.0 // Lookup CVE details from NVD API 2.0
router.get('/lookup/:cveId', async (req, res) => { router.get('/lookup/:cveId', async (req, res) => {

View File

@@ -1,27 +1,28 @@
// User Management Routes (Admin only) // User Management Routes (Admin only)
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const pool = require('../db');
const { validateTeams } = require('../helpers/teams');
function createUsersRouter(db, requireAuth, requireGroup, logAudit) { function createUsersRouter(requireAuth, requireGroup, logAudit) {
const router = express.Router(); const router = express.Router();
// All routes require Admin group // All routes require Admin group
router.use(requireAuth(db), requireGroup('Admin')); router.use(requireAuth(), requireGroup('Admin'));
// Get all users // Get all users
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const users = await new Promise((resolve, reject) => { const { rows: users } = await pool.query(
db.all( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login FROM users ORDER BY created_at DESC`
FROM users ORDER BY created_at DESC`, );
(err, rows) => { // Parse bu_teams into teams array for each user
if (err) reject(err); const usersWithTeams = users.map(u => ({
else resolve(rows); ...u,
} teams: u.bu_teams ? u.bu_teams.split(',').filter(Boolean) : []
); }));
}); res.json(usersWithTeams);
res.json(users);
} catch (err) { } catch (err) {
console.error('Get users error:', err); console.error('Get users error:', err);
res.status(500).json({ error: 'Failed to fetch users' }); res.status(500).json({ error: 'Failed to fetch users' });
@@ -31,23 +32,22 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Get single user // Get single user
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const user = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.get( `SELECT id, username, email, user_group AS "group", bu_teams, is_active, created_at, last_login
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login FROM users WHERE id = $1`,
FROM users WHERE id = ?`, [req.params.id]
[req.params.id], );
(err, row) => {
if (err) reject(err); const user = rows[0];
else resolve(row);
}
);
});
if (!user) { if (!user) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
res.json(user); res.json({
...user,
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
});
} catch (err) { } catch (err) {
console.error('Get user error:', err); console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to fetch user' }); res.status(500).json({ error: 'Failed to fetch user' });
@@ -56,7 +56,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Create new user // Create new user
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { username, email, password, group } = req.body; const { username, email, password, group, bu_teams } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
if (!username || !email || !password) { if (!username || !email || !password) {
@@ -69,28 +69,34 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' }); return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
} }
// Validate bu_teams if provided
const teamsStr = bu_teams || '';
if (teamsStr) {
const teamsResult = validateTeams(teamsStr);
if (!teamsResult.valid) {
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
}
}
try { try {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
const result = await new Promise((resolve, reject) => { const { rows } = await pool.query(
db.run( `INSERT INTO users (username, email, password_hash, user_group, bu_teams)
`INSERT INTO users (username, email, password_hash, user_group) VALUES ($1, $2, $3, $4, $5)
VALUES (?, ?, ?, ?)`, RETURNING id`,
[username, email, passwordHash, userGroup], [username, email, passwordHash, userGroup, teamsStr]
function(err) { );
if (err) reject(err);
else resolve({ id: this.lastID });
}
);
});
logAudit(db, { const result = rows[0];
logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_create', action: 'user_create',
entityType: 'user', entityType: 'user',
entityId: String(result.id), entityId: String(result.id),
details: { created_username: username, group: userGroup }, details: { created_username: username, group: userGroup, bu_teams: teamsStr },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -100,12 +106,14 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
id: result.id, id: result.id,
username, username,
email, email,
group: userGroup group: userGroup,
bu_teams: teamsStr,
teams: teamsStr ? teamsStr.split(',').filter(Boolean) : []
} }
}); });
} catch (err) { } catch (err) {
console.error('Create user error:', err); console.error('Create user error:', err);
if (err.message.includes('UNIQUE constraint failed')) { if (err.code === '23505') { // Postgres unique violation
return res.status(409).json({ error: 'Username or email already exists' }); return res.status(409).json({ error: 'Username or email already exists' });
} }
res.status(500).json({ error: 'Failed to create user' }); res.status(500).json({ error: 'Failed to create user' });
@@ -114,7 +122,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Update user // Update user
router.patch('/:id', async (req, res) => { router.patch('/:id', async (req, res) => {
const { username, email, password, group, is_active } = req.body; const { username, email, password, group, is_active, bu_teams } = req.body;
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only']; const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
const userId = req.params.id; const userId = req.params.id;
@@ -133,18 +141,24 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
return res.status(400).json({ error: 'Cannot deactivate your own account' }); return res.status(400).json({ error: 'Cannot deactivate your own account' });
} }
// Validate bu_teams if provided
if (typeof bu_teams === 'string') {
if (bu_teams !== '') {
const teamsResult = validateTeams(bu_teams);
if (!teamsResult.valid) {
return res.status(400).json({ error: `Invalid team(s): ${teamsResult.invalid.join(', ')}. Must be one of: STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV` });
}
}
}
try { try {
// Fetch current user record before update (needed for group change audit) // Fetch current user record before update (needed for group change audit)
const currentUser = await new Promise((resolve, reject) => { const { rows: currentRows } = await pool.query(
db.get( 'SELECT user_group, bu_teams FROM users WHERE id = $1',
'SELECT user_group FROM users WHERE id = ?', [userId]
[userId], );
(err, row) => {
if (err) reject(err); const currentUser = currentRows[0];
else resolve(row);
}
);
});
if (!currentUser) { if (!currentUser) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
@@ -152,27 +166,32 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
const updates = []; const updates = [];
const values = []; const values = [];
let paramIndex = 1;
if (username) { if (username) {
updates.push('username = ?'); updates.push(`username = $${paramIndex++}`);
values.push(username); values.push(username);
} }
if (email) { if (email) {
updates.push('email = ?'); updates.push(`email = $${paramIndex++}`);
values.push(email); values.push(email);
} }
if (password) { if (password) {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
updates.push('password_hash = ?'); updates.push(`password_hash = $${paramIndex++}`);
values.push(passwordHash); values.push(passwordHash);
} }
if (group) { if (group) {
updates.push('user_group = ?'); updates.push(`user_group = $${paramIndex++}`);
values.push(group); values.push(group);
} }
if (typeof is_active === 'boolean') { if (typeof is_active === 'boolean') {
updates.push('is_active = ?'); updates.push(`is_active = $${paramIndex++}`);
values.push(is_active ? 1 : 0); values.push(is_active);
}
if (typeof bu_teams === 'string') {
updates.push(`bu_teams = $${paramIndex++}`);
values.push(bu_teams);
} }
if (updates.length === 0) { if (updates.length === 0) {
@@ -181,16 +200,10 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
values.push(userId); values.push(userId);
await new Promise((resolve, reject) => { await pool.query(
db.run( `UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values
values, );
function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
}
);
});
const updatedFields = {}; const updatedFields = {};
if (username) updatedFields.username = username; if (username) updatedFields.username = username;
@@ -198,8 +211,9 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
if (group) updatedFields.group = group; if (group) updatedFields.group = group;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active; if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true; if (password) updatedFields.password_changed = true;
if (typeof bu_teams === 'string') updatedFields.bu_teams = bu_teams;
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_update', action: 'user_update',
@@ -211,7 +225,7 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
// Log specific audit entry for group changes // Log specific audit entry for group changes
if (group && group !== currentUser.user_group) { if (group && group !== currentUser.user_group) {
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_group_change', action: 'user_group_change',
@@ -225,17 +239,31 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
}); });
} }
// Log specific audit entry for bu_teams changes
if (typeof bu_teams === 'string' && bu_teams !== (currentUser.bu_teams || '')) {
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'user_teams_change',
entityType: 'user',
entityId: String(userId),
details: {
previous_teams: currentUser.bu_teams || '',
new_teams: bu_teams
},
ipAddress: req.ip
});
}
// If user was deactivated, delete their sessions // If user was deactivated, delete their sessions
if (is_active === false) { if (is_active === false) {
await new Promise((resolve) => { await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
} }
res.json({ message: 'User updated successfully' }); res.json({ message: 'User updated successfully' });
} catch (err) { } catch (err) {
console.error('Update user error:', err); console.error('Update user error:', err);
if (err.message.includes('UNIQUE constraint failed')) { if (err.code === '23505') { // Postgres unique violation
return res.status(409).json({ error: 'Username or email already exists' }); return res.status(409).json({ error: 'Username or email already exists' });
} }
res.status(500).json({ error: 'Failed to update user' }); res.status(500).json({ error: 'Failed to update user' });
@@ -253,31 +281,23 @@ function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
try { try {
// Look up the user before deleting // Look up the user before deleting
const targetUser = await new Promise((resolve, reject) => { const { rows: userRows } = await pool.query(
db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => { 'SELECT username FROM users WHERE id = $1',
if (err) reject(err); [userId]
else resolve(row); );
}); const targetUser = userRows[0];
});
// Delete sessions first (foreign key) // Delete sessions first (foreign key)
await new Promise((resolve) => { await pool.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
// Delete user // Delete user
const result = await new Promise((resolve, reject) => { const result = await pool.query('DELETE FROM users WHERE id = $1', [userId]);
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
logAudit(db, { logAudit({
userId: req.user.id, userId: req.user.id,
username: req.user.username, username: req.user.username,
action: 'user_delete', action: 'user_delete',

View File

@@ -0,0 +1,928 @@
#!/usr/bin/env node
/**
* migrate-to-postgres.js — Data Migration Script
*
* Copies all data from the SQLite database (cve_database.db) to PostgreSQL.
* The SQLite file is opened READ-ONLY and is never modified.
*
* Special handling:
* - ivanti_findings_cache.findings_json → individual rows in ivanti_findings
* - ivanti_finding_notes → merged into ivanti_findings.note column
* - ivanti_finding_overrides → merged into ivanti_findings.override_host_name / override_dns
* - ivanti_sync_state and ivanti_counts_cache → populated from ivanti_findings_cache metadata
*
* Type conversions:
* - SQLite 0/1 integers → Postgres boolean
* - SQLite DATETIME strings → Postgres TIMESTAMPTZ (passed as-is)
* - SQLite NULL → Postgres NULL
*
* Uses ON CONFLICT DO NOTHING for idempotency (safe to re-run).
*
* Usage:
* node backend/scripts/migrate-to-postgres.js
*
* Requires:
* - DATABASE_URL env var (or .env file in backend/)
* - SQLite database at backend/cve_database.db
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const { Pool } = require('pg');
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const SQLITE_PATH = path.join(__dirname, '..', 'cve_database.db');
const SCHEMA_PATH = path.join(__dirname, '..', 'db-schema.sql');
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error('ERROR: DATABASE_URL environment variable is not set.');
console.error('Expected format: postgresql://user:password@host:port/database');
process.exit(1);
}
if (!fs.existsSync(SQLITE_PATH)) {
console.error(`ERROR: SQLite database not found at ${SQLITE_PATH}`);
process.exit(1);
}
// ---------------------------------------------------------------------------
// SQLite helpers
// ---------------------------------------------------------------------------
function sqliteAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function sqliteGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
// ---------------------------------------------------------------------------
// Extract finding fields from raw JSON object (mirrors ivantiFindings.js)
// ---------------------------------------------------------------------------
function extractFinding(f) {
const rawDueDate = f.statusEmbedded?.dueDate || f.dueDate || '';
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : null;
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0]
|| f.buOwnership || f.bu_ownership || '';
const cves = Array.isArray(f.cves)
? f.cves
: (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
// Workflow extraction
let workflow = null;
if (f.workflow && typeof f.workflow === 'object') {
workflow = {
id: f.workflow.id || '',
state: f.workflow.state || '',
type: f.workflow.type || 'FP',
};
} else if (f.workflowDistribution) {
const wfDist = f.workflowDistribution || {};
const fpBuckets = [
...(wfDist.actionableWorkflows || []),
...(wfDist.requestedWorkflows || []),
...(wfDist.reworkedWorkflows || []),
...(wfDist.rejectedWorkflows || []),
...(wfDist.expiredWorkflows || []),
...(wfDist.approvedWorkflows || []),
].filter(w => (w.generatedId || '').startsWith('FP#'));
const fpEntry = fpBuckets[0] || null;
if (fpEntry) {
workflow = {
id: fpEntry.generatedId || '',
state: fpEntry.state || '',
type: 'FP',
};
}
}
return {
id: String(f.id),
hostId: f.hostId || f.host?.hostId || null,
title: f.title || '',
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
vrrGroup: f.vrrGroup || f.severityGroup || f.vrr_group || '',
hostName: f.hostName || f.host?.hostName || f.host_name || '',
ipAddress: f.ipAddress || f.host?.ipAddress || f.ip_address || '',
dns: f.dns || f.host?.fqdn || '',
status: f.status || '',
slaStatus: f.slaStatus || f.sla_status || '',
dueDate: dueDate,
lastFoundOn: f.lastFoundOn || f.last_found_on || null,
buOwnership,
cves,
workflow,
};
}
// ---------------------------------------------------------------------------
// Batch insert helper for Postgres
// ---------------------------------------------------------------------------
async function batchInsert(pool, tableName, columns, rows, conflictClause = 'DO NOTHING') {
if (rows.length === 0) return 0;
const BATCH_SIZE = 100;
let inserted = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const values = [];
const placeholders = [];
batch.forEach((row, idx) => {
const offset = idx * columns.length;
const rowPlaceholders = columns.map((_, colIdx) => `$${offset + colIdx + 1}`);
placeholders.push(`(${rowPlaceholders.join(', ')})`);
values.push(...row);
});
const sql = `INSERT INTO ${tableName} (${columns.join(', ')})
VALUES ${placeholders.join(', ')}
ON CONFLICT ${conflictClause}`;
await pool.query(sql, values);
inserted += batch.length;
}
return inserted;
}
// ---------------------------------------------------------------------------
// Table migration definitions
// ---------------------------------------------------------------------------
/**
* Each entry defines how to copy a SQLite table to Postgres.
* - sqliteTable: source table name
* - pgTable: destination table name (defaults to sqliteTable)
* - columns: array of { src, dest, transform } objects
* - conflict: ON CONFLICT clause (default: DO NOTHING)
* - selectSql: optional custom SELECT (defaults to SELECT * FROM sqliteTable)
*/
function getTableMigrations() {
return [
{
sqliteTable: 'users',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'username', dest: 'username' },
{ src: 'email', dest: 'email' },
{ src: 'password_hash', dest: 'password_hash' },
{ src: 'role', dest: 'role' },
{ src: 'is_active', dest: 'is_active', transform: v => v === 1 || v === true },
{ src: 'created_at', dest: 'created_at' },
{ src: 'last_login', dest: 'last_login' },
{ src: 'user_group', dest: 'user_group' },
{ src: 'bu_teams', dest: 'bu_teams' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'sessions',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'session_id', dest: 'session_id' },
{ src: 'user_id', dest: 'user_id' },
{ src: 'expires_at', dest: 'expires_at' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'cves',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'cve_id', dest: 'cve_id' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'severity', dest: 'severity' },
{ src: 'description', dest: 'description' },
{ src: 'published_date', dest: 'published_date' },
{ src: 'status', dest: 'status' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
{ src: 'created_by', dest: 'created_by' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'documents',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'cve_id', dest: 'cve_id' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'name', dest: 'name' },
{ src: 'type', dest: 'type' },
{ src: 'file_path', dest: 'file_path' },
{ src: 'file_size', dest: 'file_size' },
{ src: 'mime_type', dest: 'mime_type' },
{ src: 'uploaded_at', dest: 'uploaded_at' },
{ src: 'notes', dest: 'notes' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'required_documents',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'document_type', dest: 'document_type' },
{ src: 'is_mandatory', dest: 'is_mandatory', transform: v => v === 1 || v === true },
{ src: 'description', dest: 'description' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'jira_tickets',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'cve_id', dest: 'cve_id' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'ticket_key', dest: 'ticket_key' },
{ src: 'url', dest: 'url' },
{ src: 'summary', dest: 'summary' },
{ src: 'status', dest: 'status' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'archer_tickets',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'exc_number', dest: 'exc_number' },
{ src: 'archer_url', dest: 'archer_url' },
{ src: 'status', dest: 'status' },
{ src: 'cve_id', dest: 'cve_id' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'knowledge_base',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'title', dest: 'title' },
{ src: 'slug', dest: 'slug' },
{ src: 'description', dest: 'description' },
{ src: 'category', dest: 'category' },
{ src: 'file_path', dest: 'file_path' },
{ src: 'file_name', dest: 'file_name' },
{ src: 'file_type', dest: 'file_type' },
{ src: 'file_size', dest: 'file_size' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
{ src: 'created_by', dest: 'created_by' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'audit_logs',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'user_id', dest: 'user_id' },
{ src: 'username', dest: 'username' },
{ src: 'action', dest: 'action' },
{ src: 'entity_type', dest: 'entity_type' },
{ src: 'entity_id', dest: 'entity_id' },
{ src: 'details', dest: 'details' },
{ src: 'ip_address', dest: 'ip_address' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'compliance_uploads',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'filename', dest: 'filename' },
{ src: 'report_date', dest: 'report_date' },
{ src: 'uploaded_by', dest: 'uploaded_by' },
{ src: 'uploaded_at', dest: 'uploaded_at' },
{ src: 'new_count', dest: 'new_count' },
{ src: 'resolved_count', dest: 'resolved_count' },
{ src: 'recurring_count', dest: 'recurring_count' },
{ src: 'summary_json', dest: 'summary_json' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'compliance_items',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'upload_id', dest: 'upload_id' },
{ src: 'hostname', dest: 'hostname' },
{ src: 'ip_address', dest: 'ip_address' },
{ src: 'device_type', dest: 'device_type' },
{ src: 'team', dest: 'team' },
{ src: 'metric_id', dest: 'metric_id' },
{ src: 'metric_desc', dest: 'metric_desc' },
{ src: 'category', dest: 'category' },
{ src: 'extra_json', dest: 'extra_json' },
{ src: 'status', dest: 'status' },
{ src: 'first_seen_upload_id', dest: 'first_seen_upload_id' },
{ src: 'resolved_upload_id', dest: 'resolved_upload_id' },
{ src: 'seen_count', dest: 'seen_count' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'compliance_notes',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'hostname', dest: 'hostname' },
{ src: 'metric_id', dest: 'metric_id' },
{ src: 'note', dest: 'note' },
{ src: 'group_id', dest: 'group_id' },
{ src: 'created_by', dest: 'created_by' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_counts_history',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'open_count', dest: 'open_count' },
{ src: 'closed_count', dest: 'closed_count' },
{ src: 'recorded_at', dest: 'recorded_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_finding_archives',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'finding_id', dest: 'finding_id' },
{ src: 'finding_title', dest: 'finding_title' },
{ src: 'host_name', dest: 'host_name' },
{ src: 'ip_address', dest: 'ip_address' },
{ src: 'current_state', dest: 'current_state' },
{ src: 'last_severity', dest: 'last_severity' },
{ src: 'first_archived_at', dest: 'first_archived_at' },
{ src: 'last_transition_at', dest: 'last_transition_at' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_archive_transitions',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'archive_id', dest: 'archive_id' },
{ src: 'from_state', dest: 'from_state' },
{ src: 'to_state', dest: 'to_state' },
{ src: 'severity_at_transition', dest: 'severity_at_transition' },
{ src: 'reason', dest: 'reason' },
{ src: 'transitioned_at', dest: 'transitioned_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_sync_anomaly_log',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'sync_timestamp', dest: 'sync_timestamp' },
{ src: 'open_count_delta', dest: 'open_count_delta' },
{ src: 'closed_count_delta', dest: 'closed_count_delta' },
{ src: 'newly_archived_count', dest: 'newly_archived_count' },
{ src: 'returned_count', dest: 'returned_count' },
{ src: 'classification_json', dest: 'classification_json' },
{ src: 'return_classification_json', dest: 'return_classification_json' },
{ src: 'is_significant', dest: 'is_significant', transform: v => v === 1 || v === true },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_finding_bu_history',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'finding_id', dest: 'finding_id' },
{ src: 'finding_title', dest: 'finding_title' },
{ src: 'host_name', dest: 'host_name' },
{ src: 'previous_bu', dest: 'previous_bu' },
{ src: 'new_bu', dest: 'new_bu' },
{ src: 'detected_at', dest: 'detected_at' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'atlas_action_plans_cache',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'host_id', dest: 'host_id' },
{ src: 'has_action_plan', dest: 'has_action_plan', transform: v => v === 1 || v === true },
{ src: 'plan_count', dest: 'plan_count' },
{ src: 'plans_json', dest: 'plans_json' },
{ src: 'synced_at', dest: 'synced_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_fp_submissions',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'user_id', dest: 'user_id' },
{ src: 'username', dest: 'username' },
{ src: 'ivanti_workflow_batch_id', dest: 'ivanti_workflow_batch_id' },
{ src: 'ivanti_generated_id', dest: 'ivanti_generated_id' },
{ src: 'ivanti_workflow_batch_uuid', dest: 'ivanti_workflow_batch_uuid' },
{ src: 'workflow_name', dest: 'workflow_name' },
{ src: 'reason', dest: 'reason' },
{ src: 'description', dest: 'description' },
{ src: 'expiration_date', dest: 'expiration_date' },
{ src: 'scope_override', dest: 'scope_override' },
{ src: 'finding_ids_json', dest: 'finding_ids_json' },
{ src: 'queue_item_ids_json', dest: 'queue_item_ids_json' },
{ src: 'attachment_count', dest: 'attachment_count' },
{ src: 'attachment_results_json', dest: 'attachment_results_json' },
{ src: 'status', dest: 'status' },
{ src: 'lifecycle_status', dest: 'lifecycle_status' },
{ src: 'error_message', dest: 'error_message' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_fp_submission_history',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'submission_id', dest: 'submission_id' },
{ src: 'user_id', dest: 'user_id' },
{ src: 'username', dest: 'username' },
{ src: 'change_type', dest: 'change_type' },
{ src: 'change_details_json', dest: 'change_details_json' },
{ src: 'created_at', dest: 'created_at' },
],
conflict: '(id) DO NOTHING',
},
{
sqliteTable: 'ivanti_todo_queue',
columns: [
{ src: 'id', dest: 'id' },
{ src: 'user_id', dest: 'user_id' },
{ src: 'finding_id', dest: 'finding_id' },
{ src: 'finding_title', dest: 'finding_title' },
{ src: 'cves_json', dest: 'cves_json' },
{ src: 'ip_address', dest: 'ip_address' },
{ src: 'hostname', dest: 'hostname' },
{ src: 'vendor', dest: 'vendor' },
{ src: 'workflow_type', dest: 'workflow_type' },
{ src: 'status', dest: 'status' },
{ src: 'created_at', dest: 'created_at' },
{ src: 'updated_at', dest: 'updated_at' },
],
conflict: '(id) DO NOTHING',
},
];
}
// ---------------------------------------------------------------------------
// Main migration logic
// ---------------------------------------------------------------------------
async function migrate() {
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ CVE Dashboard — SQLite → PostgreSQL Migration ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
// Open SQLite in READ-ONLY mode
const sqliteDb = new sqlite3.Database(SQLITE_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) {
console.error('ERROR: Failed to open SQLite database:', err.message);
process.exit(1);
}
});
console.log(`✓ Opened SQLite database (read-only): ${SQLITE_PATH}`);
// Connect to Postgres
const pool = new Pool({
connectionString: DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
try {
await pool.query('SELECT NOW()');
console.log('✓ Connected to PostgreSQL');
} catch (err) {
console.error('ERROR: Failed to connect to PostgreSQL:', err.message);
sqliteDb.close();
process.exit(1);
}
// Step 1: Run schema DDL
console.log('\n── Step 1: Creating schema (idempotent) ──');
try {
const schemaSql = fs.readFileSync(SCHEMA_PATH, 'utf8');
await pool.query(schemaSql);
console.log('✓ Schema created/verified');
} catch (err) {
console.error('ERROR: Schema creation failed:', err.message);
await cleanup(sqliteDb, pool);
process.exit(1);
}
// Step 2: Copy simple tables
console.log('\n── Step 2: Copying tables ──');
const migrations = getTableMigrations();
const migrationResults = {};
for (const migration of migrations) {
const tableName = migration.pgTable || migration.sqliteTable;
try {
// Check if table exists in SQLite
const tableCheck = await sqliteGet(
sqliteDb,
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
[migration.sqliteTable]
);
if (!tableCheck) {
console.log(`${migration.sqliteTable} — table not found in SQLite, skipping`);
migrationResults[tableName] = { source: 0, dest: 0, skipped: true };
continue;
}
// Read all rows from SQLite
const selectSql = migration.selectSql || `SELECT * FROM ${migration.sqliteTable}`;
const sourceRows = await sqliteAll(sqliteDb, selectSql);
if (sourceRows.length === 0) {
console.log(`${tableName} — 0 rows (empty table)`);
migrationResults[tableName] = { source: 0, dest: 0 };
continue;
}
// Transform rows
const destColumns = migration.columns.map(c => c.dest);
const transformedRows = sourceRows.map(row => {
return migration.columns.map(col => {
let value = row[col.src];
if (value === undefined) value = null;
if (col.transform && value !== null) {
value = col.transform(value);
}
return value;
});
});
// Insert into Postgres
const inserted = await batchInsert(
pool,
tableName,
destColumns,
transformedRows,
migration.conflict
);
console.log(`${tableName}${inserted} rows copied`);
migrationResults[tableName] = { source: sourceRows.length, dest: inserted };
} catch (err) {
console.error(`${tableName} — ERROR: ${err.message}`);
migrationResults[tableName] = { source: 0, dest: 0, error: err.message };
}
}
// Reset sequences for SERIAL columns after bulk insert with explicit IDs
console.log('\n── Step 2b: Resetting sequences ──');
const serialTables = [
'users', 'sessions', 'cves', 'documents', 'required_documents',
'jira_tickets', 'archer_tickets', 'knowledge_base', 'audit_logs',
'compliance_uploads', 'compliance_items', 'compliance_notes',
'ivanti_counts_history', 'ivanti_finding_archives',
'ivanti_archive_transitions', 'ivanti_sync_anomaly_log',
'ivanti_finding_bu_history', 'atlas_action_plans_cache',
'ivanti_fp_submissions', 'ivanti_fp_submission_history',
'ivanti_todo_queue',
];
for (const table of serialTables) {
try {
await pool.query(`
SELECT setval(pg_get_serial_sequence('${table}', 'id'),
COALESCE((SELECT MAX(id) FROM ${table}), 0) + 1, false)
`);
} catch (err) {
// Non-fatal — sequence may not exist for some tables
console.log(` ⚠ Could not reset sequence for ${table}: ${err.message}`);
}
}
console.log('✓ Sequences reset');
// Step 3: Migrate findings from JSON blob
console.log('\n── Step 3: Migrating findings from JSON blob ──');
try {
const cacheRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_findings_cache WHERE id = 1');
if (!cacheRow || !cacheRow.findings_json) {
console.log(' ⚠ No findings_json data found in ivanti_findings_cache');
} else {
let findings;
try {
findings = JSON.parse(cacheRow.findings_json);
} catch (parseErr) {
console.error(' ✗ Failed to parse findings_json:', parseErr.message);
findings = [];
}
if (Array.isArray(findings) && findings.length > 0) {
console.log(` Parsing ${findings.length} findings from JSON blob...`);
// Extract and insert findings
const BATCH_SIZE = 100;
let insertedCount = 0;
for (let i = 0; i < findings.length; i += BATCH_SIZE) {
const batch = findings.slice(i, i + BATCH_SIZE);
const values = [];
const placeholders = [];
batch.forEach((rawFinding, idx) => {
const f = extractFinding(rawFinding);
const offset = idx * 18;
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,
f.workflow ? f.workflow.id : null,
f.workflow ? f.workflow.state : null,
f.workflow ? f.workflow.type : null,
'open' // state = open for all findings from cache
);
placeholders.push(
`($${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}, $${offset+15}, ` +
`$${offset+16}, $${offset+17}, $${offset+18})`
);
});
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,
workflow_id, workflow_state, workflow_type, state
)
VALUES ${placeholders.join(', ')}
ON CONFLICT (id) DO NOTHING
`, values);
insertedCount += batch.length;
}
console.log(` ✓ ivanti_findings — ${insertedCount} findings inserted (state='open')`);
migrationResults['ivanti_findings'] = { source: findings.length, dest: insertedCount };
} else {
console.log(' ○ findings_json is empty or not an array');
migrationResults['ivanti_findings'] = { source: 0, dest: 0 };
}
}
} catch (err) {
console.error(` ✗ Findings migration ERROR: ${err.message}`);
migrationResults['ivanti_findings'] = { source: 0, dest: 0, error: err.message };
}
// Step 4: Merge notes into ivanti_findings.note
console.log('\n── Step 4: Merging finding notes ──');
try {
const notes = await sqliteAll(sqliteDb, 'SELECT finding_id, note FROM ivanti_finding_notes');
if (notes.length === 0) {
console.log(' ○ No finding notes to merge');
} else {
let mergedCount = 0;
for (const { finding_id, note } of notes) {
if (!finding_id || !note) continue;
const result = await pool.query(
`UPDATE ivanti_findings SET note = $1 WHERE id = $2`,
[note, finding_id]
);
if (result.rowCount > 0) mergedCount++;
}
console.log(` ✓ Merged ${mergedCount}/${notes.length} notes into ivanti_findings.note`);
}
} catch (err) {
console.error(` ✗ Notes merge ERROR: ${err.message}`);
}
// Step 5: Merge overrides into ivanti_findings.override_host_name / override_dns
console.log('\n── Step 5: Merging finding overrides ──');
try {
const overrides = await sqliteAll(
sqliteDb,
'SELECT finding_id, field, value FROM ivanti_finding_overrides'
);
if (overrides.length === 0) {
console.log(' ○ No finding overrides to merge');
} else {
let mergedCount = 0;
for (const { finding_id, field, value } of overrides) {
if (!finding_id || !field) continue;
let pgColumn;
if (field === 'host_name' || field === 'hostName' || field === 'override_host_name') {
pgColumn = 'override_host_name';
} else if (field === 'dns' || field === 'override_dns') {
pgColumn = 'override_dns';
} else {
// Unknown field — skip
continue;
}
const result = await pool.query(
`UPDATE ivanti_findings SET ${pgColumn} = $1 WHERE id = $2`,
[value, finding_id]
);
if (result.rowCount > 0) mergedCount++;
}
console.log(` ✓ Merged ${mergedCount}/${overrides.length} overrides into ivanti_findings`);
}
} catch (err) {
console.error(` ✗ Overrides merge ERROR: ${err.message}`);
}
// Step 6: Populate ivanti_sync_state from ivanti_findings_cache metadata
console.log('\n── Step 6: Populating sync state and counts cache ──');
try {
const cacheRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_findings_cache WHERE id = 1');
if (cacheRow) {
await pool.query(`
UPDATE ivanti_sync_state SET
total = $1,
synced_at = $2,
sync_status = $3,
error_message = $4
WHERE id = 1
`, [
cacheRow.total || 0,
cacheRow.synced_at || null,
cacheRow.sync_status || 'never',
cacheRow.error_message || null,
]);
console.log(' ✓ ivanti_sync_state updated from ivanti_findings_cache metadata');
}
// Populate ivanti_counts_cache
const countsRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_counts_cache WHERE id = 1');
if (countsRow) {
await pool.query(`
UPDATE ivanti_counts_cache SET
open_count = $1,
closed_count = $2,
synced_at = $3,
fp_workflow_counts_json = $4,
fp_id_counts_json = $5
WHERE id = 1
`, [
countsRow.open_count || 0,
countsRow.closed_count || 0,
countsRow.synced_at || null,
countsRow.fp_workflow_counts_json || '{}',
countsRow.fp_id_counts_json || '{}',
]);
console.log(' ✓ ivanti_counts_cache updated');
}
} catch (err) {
console.error(` ✗ Sync state/counts migration ERROR: ${err.message}`);
}
// Step 7: Verification — compare row counts
console.log('\n── Step 7: Verification ──');
console.log('');
console.log('┌─────────────────────────────────┬──────────┬──────────┬────────┐');
console.log('│ Table │ SQLite │ Postgres │ Status │');
console.log('├─────────────────────────────────┼──────────┼──────────┼────────┤');
let hasDiscrepancy = false;
const verificationTables = [
...migrations.map(m => ({ sqlite: m.sqliteTable, pg: m.pgTable || m.sqliteTable })),
{ sqlite: null, pg: 'ivanti_findings', special: true },
];
for (const { sqlite: sqliteTable, pg: pgTable, special } of verificationTables) {
let sqliteCount = 0;
let pgCount = 0;
try {
if (special && pgTable === 'ivanti_findings') {
// For findings, source count is from the JSON blob
const cacheRow = await sqliteGet(sqliteDb, 'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1');
if (cacheRow && cacheRow.findings_json) {
try {
const parsed = JSON.parse(cacheRow.findings_json);
sqliteCount = Array.isArray(parsed) ? parsed.length : 0;
} catch (e) {
sqliteCount = 0;
}
}
} else if (sqliteTable) {
const tableExists = await sqliteGet(
sqliteDb,
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
[sqliteTable]
);
if (tableExists) {
const countRow = await sqliteGet(sqliteDb, `SELECT COUNT(*) as cnt FROM ${sqliteTable}`);
sqliteCount = countRow ? countRow.cnt : 0;
}
}
const pgCountRow = await pool.query(`SELECT COUNT(*) as cnt FROM ${pgTable}`);
pgCount = parseInt(pgCountRow.rows[0].cnt, 10);
} catch (err) {
// Table might not exist in one or both
}
const status = pgCount >= sqliteCount ? ' OK ' : ' WARN ';
if (pgCount < sqliteCount) hasDiscrepancy = true;
const tableDisplay = (pgTable || '').padEnd(31);
const srcDisplay = String(sqliteCount).padStart(6);
const destDisplay = String(pgCount).padStart(6);
console.log(`${tableDisplay}${srcDisplay}${destDisplay}${status}`);
}
console.log('└─────────────────────────────────┴──────────┴──────────┴────────┘');
if (hasDiscrepancy) {
console.log('\n⚠ WARNING: Some tables have fewer rows in Postgres than SQLite.');
console.log(' This may be due to ON CONFLICT DO NOTHING skipping existing rows,');
console.log(' or foreign key constraints preventing insertion.');
}
// Cleanup
await cleanup(sqliteDb, pool);
console.log('\n════════════════════════════════════════════════════════');
if (hasDiscrepancy) {
console.log('Migration completed with warnings. Review discrepancies above.');
} else {
console.log('✓ Migration completed successfully!');
}
console.log('════════════════════════════════════════════════════════\n');
process.exit(hasDiscrepancy ? 0 : 0); // Exit 0 even with warnings (data is safe)
}
// ---------------------------------------------------------------------------
// Cleanup helper
// ---------------------------------------------------------------------------
function cleanup(sqliteDb, pool) {
return new Promise((resolve) => {
sqliteDb.close((err) => {
if (err) console.error('Warning: Error closing SQLite:', err.message);
pool.end()
.then(() => resolve())
.catch(() => resolve());
});
});
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
migrate().catch((err) => {
console.error('\n✗ FATAL ERROR:', err.message);
console.error(err.stack);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

49
backend/setup-postgres.js Normal file
View File

@@ -0,0 +1,49 @@
// Setup Script for CVE Dashboard — PostgreSQL
// Runs the db-schema.sql DDL against the Postgres instance configured in DATABASE_URL.
// Idempotent — safe to run multiple times.
//
// Usage: node backend/setup-postgres.js
//
// Requires DATABASE_URL in .env or environment.
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const fs = require('fs');
const path = require('path');
const pool = require('./db');
const SCHEMA_FILE = path.join(__dirname, 'db-schema.sql');
async function main() {
console.log('🚀 CVE Dashboard — PostgreSQL Schema Setup\n');
console.log('════════════════════════════════════════\n');
try {
// Verify connection
const { rows } = await pool.query('SELECT version()');
console.log(`✓ Connected to: ${rows[0].version.split(',')[0]}`);
console.log(` Database URL: ${process.env.DATABASE_URL.replace(/:[^:@]+@/, ':***@')}\n`);
// Read and execute schema
const schema = fs.readFileSync(SCHEMA_FILE, 'utf8');
await pool.query(schema);
console.log('✓ Schema created/verified (all tables and indexes)\n');
// Verify table count
const { rows: tables } = await pool.query(
"SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = 'public'"
);
console.log(`${tables[0].count} tables in database\n`);
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ POSTGRESQL SCHEMA SETUP COMPLETE ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
} catch (err) {
console.error('❌ Setup failed:', err.message);
process.exit(1);
} finally {
await pool.end();
}
}
main();

View File

@@ -114,6 +114,7 @@ async function initializeDatabase(db) {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP, last_login TIMESTAMP,
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only', user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only',
bu_teams TEXT NOT NULL DEFAULT '',
CHECK (role IN ('admin', 'editor', 'viewer')) CHECK (role IN ('admin', 'editor', 'viewer'))
); );

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
# Docker Compose for CVE Dashboard PostgreSQL
# Run: docker compose up -d
# Stop: docker compose down
# View logs: docker compose logs -f postgres
services:
postgres:
image: postgres:16-alpine
container_name: steam-postgres
restart: unless-stopped
environment:
POSTGRES_DB: cve_dashboard
POSTGRES_USER: steam
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sV4xmC9xAUCFop0ypxMVS056QgPqGrX}
ports:
- "5433:5432"
volumes:
- steam-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U steam -d cve_dashboard"]
interval: 10s
timeout: 5s
retries: 5
volumes:
steam-pgdata:

View File

@@ -19,9 +19,9 @@ All API calls are made from a single Node.js backend process. The integration us
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests | | Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked | | Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked | | No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs | | Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with predefined key-based JQL query parameters, not per-issue GETs; no arbitrary JQL passthrough |
| Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` | | Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` |
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = <KEY>` scoping | | JQL scoping | All recurring JQL queries use predefined scoped patterns with `updated >= -72h` clause and `project = <KEY>` scoping; no arbitrary JQL passthrough |
| `maxResults` cap | Search queries capped at 1 000 results per page | | `maxResults` cap | Search queries capped at 1 000 results per page |
--- ---
@@ -96,7 +96,7 @@ All API calls are made from a single Node.js backend process. The integration us
| **Frequency** | Manual, estimated 510 per day | | **Frequency** | Manual, estimated 510 per day |
| **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) | | **Purpose** | Move ticket through workflow states (e.g., Open to In Progress to Closed) |
### 8. JQL Search (Bulk Sync) ### 8. Scoped Bulk Sync via JQL
| | | | | |
|---|---| |---|---|
@@ -104,10 +104,10 @@ All API calls are made from a single Node.js backend process. The integration us
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel | | **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
| **Frequency** | Manual, estimated 13 times per day | | **Frequency** | Manual, estimated 13 times per day |
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs | | **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = <KEY>` | | **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -72h AND project = <KEY>` |
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` | | **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
| **Batch size** | 100 keys per JQL query; multiple batches if needed | | **Batch size** | 100 keys per JQL query; multiple batches if needed |
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) | | **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. JQL is predefined and scoped — constructed from known tracked issue keys, a fixed 72-hour window, and the configured project key. No arbitrary JQL is accepted from the frontend. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
### 9. Issue Lookup ### 9. Issue Lookup
@@ -131,7 +131,7 @@ All API calls are made from a single Node.js backend process. The integration us
| Add comment | 515 | POST | 2s | | Add comment | 515 | POST | 2s |
| Get transitions | 510 | GET | 1s | | Get transitions | 510 | GET | 1s |
| Transition issue | 510 | POST | 2s | | Transition issue | 510 | POST | 2s |
| JQL search (sync) | 15 | GET | 1s | | Scoped bulk sync | 15 | GET | 1s |
| Issue lookup | 515 | GET | 1s | | Issue lookup | 515 | GET | 1s |
| **Total estimated** | **43120** | | | | **Total estimated** | **43120** | | |
@@ -145,6 +145,7 @@ The integration explicitly blocks these endpoints to comply with Charter policy:
- `/rest/api/2/field` — field metadata is never queried; fields are specified in code - `/rest/api/2/field` — field metadata is never queried; fields are specified in code
- `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually - `/rest/api/2/issue/bulk` — bulk updates are not used; issues are updated individually
- `POST /rest/api/2/search` — arbitrary JQL search via POST is not used; all searches use `GET /rest/api/2/search` with URL-encoded query parameters and predefined scoped JQL patterns
--- ---

View File

@@ -0,0 +1,289 @@
# Postgres Migration Plan
## Overview
Migrate the STEAM Security Dashboard from SQLite (`cve_database.db`) to PostgreSQL. This eliminates the JSON blob performance bottleneck, enables per-BU closed finding counts, and supports the multi-tenancy feature properly.
## Current State
- **Database**: SQLite 3, single file `backend/cve_database.db` (13MB)
- **Performance bottleneck**: `ivanti_findings_cache.findings_json` — a 2.6MB TEXT column holding all findings as serialized JSON, parsed on every API request
- **Limitation**: No per-BU closed finding data (only a global count)
- **Concurrency**: SQLite single-writer lock blocks reads during sync writes
## Target State
- **Database**: PostgreSQL 16 (Docker container on port 5433)
- **Findings storage**: Individual rows in `ivanti_findings` table with indexed columns
- **Closed findings**: Stored as rows with `state = 'closed'` and `bu_ownership` column
- **Per-BU counts**: Simple `SELECT COUNT(*) WHERE state = ? AND bu_ownership LIKE ?`
- **Concurrency**: Connection pool (10 connections), reads never blocked by writes
## Infrastructure
### Port Allocation
| Port | Service | Status |
|------|---------|--------|
| 3000 | Frontend (production) | In use |
| 3001 | Backend (production) | In use |
| 3002 | Other project (Python) | In use — do not touch |
| 3003 | Test backend (temporary, during migration dev) | Available |
| 5000 | Other project (Python) | In use — do not touch |
| 5432 | Other project (Postgres) | In use — do not touch |
| 5433 | CVE Dashboard Postgres (Docker) | Available — ours |
### Docker Setup
```bash
docker run -d --name steam-postgres \
--restart unless-stopped \
-e POSTGRES_DB=cve_dashboard \
-e POSTGRES_USER=steam \
-e POSTGRES_PASSWORD=<generated-password> \
-p 5433:5432 \
-v steam-pgdata:/var/lib/postgresql/data \
postgres:16-alpine
```
### Connection String
```
postgresql://steam:<password>@localhost:5433/cve_dashboard
```
Added to `backend/.env` as `DATABASE_URL`.
## Migration Strategy
### Approach: Blue-Green on Same Box
1. Production stays on SQLite (port 3001) throughout development
2. New Postgres backend tested on port 3003
3. Cutover: stop old backend, start new backend on port 3001
4. Rollback: stop new backend, start old SQLite backend
### Branch Strategy
All work happens on `feature/multi-tenancy` branch (same branch as the multi-BU work). The Postgres migration is the infrastructure that makes multi-BU tenancy performant.
## Schema Design
### Key Changes from SQLite
| SQLite | PostgreSQL |
|--------|-----------|
| `findings_json` TEXT blob (2.6MB) | `ivanti_findings` table — one row per finding |
| Single `ivanti_counts_cache` row | Derived from `ivanti_findings` via queries |
| `TEXT` for everything | Proper types: `INTEGER`, `NUMERIC`, `TIMESTAMPTZ`, `TEXT[]` |
| No concurrent writes | Connection pool, MVCC |
| File-based | Docker volume `steam-pgdata` |
### New `ivanti_findings` Table
```sql
CREATE TABLE ivanti_findings (
id TEXT PRIMARY KEY, -- Ivanti finding ID
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 idx_findings_state ON ivanti_findings(state);
CREATE INDEX idx_findings_bu ON ivanti_findings(bu_ownership);
CREATE INDEX idx_findings_severity ON ivanti_findings(severity);
CREATE INDEX idx_findings_state_bu ON ivanti_findings(state, bu_ownership);
```
### Per-BU Counts (No Separate Table Needed)
```sql
-- Open count for STEAM
SELECT COUNT(*) FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE '%STEAM%';
-- Closed count for STEAM
SELECT COUNT(*) FROM ivanti_findings WHERE state = 'closed' AND bu_ownership ILIKE '%STEAM%';
-- All BU counts in one query
SELECT
bu_ownership,
state,
COUNT(*) as count
FROM ivanti_findings
GROUP BY bu_ownership, state;
```
## Data Migration Script
A one-time script (`backend/scripts/migrate-to-postgres.js`) that:
1. Opens the SQLite database (read-only)
2. Connects to Postgres
3. Creates all tables (idempotent — `IF NOT EXISTS`)
4. Copies data table by table:
- `users``users` (direct copy + `bu_teams`)
- `sessions``sessions`
- `cves``cves`
- `documents``documents`
- `jira_tickets``jira_tickets`
- `archer_tickets``archer_tickets`
- `knowledge_base``knowledge_base`
- `audit_logs``audit_logs`
- `compliance_uploads``compliance_uploads`
- `compliance_items``compliance_items`
- `compliance_notes``compliance_notes`
- `ivanti_findings_cache.findings_json` → individual rows in `ivanti_findings` (state='open')
- `ivanti_finding_notes` → merged into `ivanti_findings.note`
- `ivanti_finding_overrides` → merged into `ivanti_findings.override_*`
- `ivanti_counts_history``ivanti_counts_history`
- `ivanti_finding_archives``ivanti_finding_archives`
- `ivanti_archive_transitions``ivanti_archive_transitions`
- `ivanti_sync_anomaly_log``ivanti_sync_anomaly_log`
- `ivanti_finding_bu_history``ivanti_finding_bu_history`
- `atlas_action_plans_cache``atlas_action_plans_cache`
- `ivanti_fp_submissions``ivanti_fp_submissions`
- `ivanti_fp_submission_history``ivanti_fp_submission_history`
- `ivanti_todo_queue``ivanti_todo_queue`
5. Verifies row counts match
6. Prints summary
## Code Changes
### Backend
1. **New dependency**: `pg` (node-postgres) replaces `sqlite3`
2. **Connection pool**: `backend/db.js` — creates and exports a `Pool` instance
3. **Query pattern change**:
```js
// Before (SQLite callback):
db.get('SELECT * FROM users WHERE id = ?', [id], (err, row) => { ... });
// After (Postgres async):
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
const row = rows[0];
```
4. **Placeholder syntax**: `?` → `$1, $2, $3...`
5. **Findings sync**: Write individual rows via `INSERT ... ON CONFLICT (id) DO UPDATE`
6. **Closed findings sync**: Same pattern — upsert with `state = 'closed'`
7. **Counts**: Derived queries instead of a cache table
### Frontend
No changes needed — the API contract stays the same. The frontend already does client-side filtering.
## Cutover Procedure
```bash
# 1. Final sync on production (SQLite) to get latest data
curl -X POST http://localhost:3001/api/ivanti/findings/sync
# 2. Run migration script (copies SQLite → Postgres)
node backend/scripts/migrate-to-postgres.js
# 3. Stop production backend
systemctl stop cve-backend # or kill the process
# 4. Update .env to use Postgres
# DATABASE_URL=postgresql://steam:<pass>@localhost:5433/cve_dashboard
# DB_TYPE=postgres
# 5. Start new backend on same port
systemctl start cve-backend # now uses Postgres
# 6. Verify
curl http://localhost:3001/api/auth/me # should work
```
### Rollback (if needed)
```bash
# 1. Stop new backend
systemctl stop cve-backend
# 2. Revert .env
# DB_TYPE=sqlite (or remove DATABASE_URL)
# 3. Start old backend
systemctl start cve-backend
```
## Timeline Estimate
| Phase | Effort | Description |
|-------|--------|-------------|
| Docker setup | 5 min | One command |
| Schema creation | 1 hour | SQL DDL for all tables |
| DB abstraction layer | 2-3 hours | `backend/db.js` pool + query helpers |
| Route migration | 4-6 hours | Update all routes from sqlite3 callbacks to pg async |
| Findings redesign | 2-3 hours | New sync logic writing individual rows |
| Closed findings | 1-2 hours | Store closed findings, per-BU count queries |
| Data migration script | 1-2 hours | SQLite → Postgres copy |
| Testing | 2-3 hours | Verify all endpoints, sync, UI |
| Cutover | 30 min | Stop/start + verify |
| **Total** | **~15-20 hours** | |
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| Docker container crashes | `--restart unless-stopped` flag |
| Data loss during cutover | SQLite file preserved as backup forever |
| Postgres disk fills up | Docker volume on main disk; monitor with `df` |
| Connection pool exhaustion | Pool max = 10, with queue; log warnings at 8 |
| Migration script bugs | Run against dev DB first; verify row counts |
## Post-Migration Benefits
- **Instant BU filtering**: `WHERE bu_ownership ILIKE '%STEAM%'` on indexed column
- **Per-BU closed counts**: No more "N/A" — real numbers per team
- **No JSON parsing**: Findings are rows, not a blob
- **Concurrent access**: Multiple users can read while sync writes
- **Future-proof**: Easy to add full-text search, materialized views, partitioning
## Docker Container Setup
Run this once to create the Postgres container:
```bash
docker run -d --name steam-postgres \
--restart unless-stopped \
-e POSTGRES_DB=cve_dashboard \
-e POSTGRES_USER=steam \
-e POSTGRES_PASSWORD=sV4xmC9xAUCFop0ypxMVS056QgPqGrX \
-p 5433:5432 \
-v steam-pgdata:/var/lib/postgresql/data \
postgres:16-alpine
```
Verify it's running:
```bash
docker ps | grep steam-postgres
psql -h localhost -p 5433 -U steam -d cve_dashboard -c "SELECT 1;"
```
Management commands:
```bash
docker stop steam-postgres # Stop
docker start steam-postgres # Start
docker logs steam-postgres # View logs
docker exec -it steam-postgres psql -U steam -d cve_dashboard # Shell access
```

View File

@@ -7,6 +7,7 @@ import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog'; import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal'; import NvdSyncModal from './components/NvdSyncModal';
import NavDrawer from './components/NavDrawer'; import NavDrawer from './components/NavDrawer';
import AdminScopeToggle from './components/AdminScopeToggle';
import CalendarWidget from './components/CalendarWidget'; import CalendarWidget from './components/CalendarWidget';
import ConfirmModal from './components/ConfirmModal'; import ConfirmModal from './components/ConfirmModal';
import VulnerabilityTriagePage from './components/pages/ReportingPage'; import VulnerabilityTriagePage from './components/pages/ReportingPage';
@@ -1020,6 +1021,7 @@ export default function App() {
Add Entry Add Entry
</button> </button>
)} )}
<AdminScopeToggle />
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} /> <UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,166 @@
// AdminScopeToggle.js
// Multi-select BU scope picker for Admin users.
// Allows selecting any combination of teams to filter Reporting/Compliance/Exports.
// "My Teams" preset selects the admin's assigned bu_teams.
// "All BUs" preset selects everything.
// Custom selections are persisted in localStorage.
import React, { useState, useRef, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
function AdminScopeToggle() {
const { isAdmin, user, adminScope, setAdminScopeTeams, KNOWN_TEAMS, hasTeams } = useAuth();
const [open, setOpen] = useState(false);
const ref = useRef(null);
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Only render for Admin users
if (!isAdmin()) return null;
const selectedTeams = adminScope || [];
const myTeams = user?.teams || [];
const allSelected = selectedTeams.length === KNOWN_TEAMS.length;
const isMyTeams = myTeams.length > 0 &&
selectedTeams.length === myTeams.length &&
myTeams.every(t => selectedTeams.includes(t));
const toggleTeam = (team) => {
const next = selectedTeams.includes(team)
? selectedTeams.filter(t => t !== team)
: [...selectedTeams, team];
setAdminScopeTeams(next);
};
const selectAll = () => setAdminScopeTeams([...KNOWN_TEAMS]);
const selectMyTeams = () => setAdminScopeTeams([...myTeams]);
// Label for the button
let label;
if (allSelected || selectedTeams.length === 0) {
label = 'All BUs';
} else if (isMyTeams) {
label = 'My Teams';
} else {
label = selectedTeams.join(', ');
}
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setOpen(!open)}
aria-label="Select BU scope"
aria-expanded={open}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.05)',
border: '1px solid rgba(14, 165, 233, 0.2)',
fontSize: '0.68rem',
fontFamily: 'monospace',
fontWeight: '600',
color: '#0EA5E9',
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
<span>{label}</span>
<span style={{ fontSize: '0.6rem', color: '#475569' }}></span>
</button>
{open && (
<div style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 100,
minWidth: '180px',
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '0.5rem 0',
}}>
{/* Presets */}
<div style={{ padding: '0.25rem 0.75rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={selectAll}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: allSelected ? 'rgba(139,92,246,0.12)' : 'none',
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
color: allSelected ? '#A78BFA' : '#94A3B8',
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: 'pointer', marginBottom: '0.2rem',
}}
>
All BUs
</button>
{myTeams.length > 0 && (
<button
onClick={selectMyTeams}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: isMyTeams ? 'rgba(14,165,233,0.12)' : 'none',
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
color: isMyTeams ? '#0EA5E9' : '#94A3B8',
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: 'pointer',
}}
>
My Teams ({myTeams.join(', ')})
</button>
)}
</div>
{/* Individual team checkboxes */}
<div style={{ padding: '0.5rem 0.75rem' }}>
{KNOWN_TEAMS.map(team => {
const checked = selectedTeams.includes(team);
return (
<label
key={team}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
padding: '0.3rem 0.25rem', cursor: 'pointer',
borderRadius: '0.2rem',
fontSize: '0.72rem', fontFamily: 'monospace',
color: checked ? '#E2E8F0' : '#64748B',
fontWeight: checked ? '600' : '400',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTeam(team)}
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
/>
{team}
</label>
);
})}
</div>
</div>
)}
</div>
);
}
export default AdminScopeToggle;

View File

@@ -510,6 +510,36 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
} }
const data = await res.json(); const data = await res.json();
const remotePlans = parseAtlasPlans(data); const remotePlans = parseAtlasPlans(data);
// If Atlas returns no plans, check local cache for optimistic bulk-create stubs
if (remotePlans.length === 0) {
try {
const cacheRes = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
if (cacheRes.ok) {
const cacheData = await cacheRes.json();
const hostCache = cacheData.find(r => r.host_id === hostId);
if (hostCache && hostCache.has_action_plan === 1 && hostCache.plans_json) {
let cachedPlans = [];
try { cachedPlans = typeof hostCache.plans_json === 'string' ? JSON.parse(hostCache.plans_json) : hostCache.plans_json; } catch (_) {}
const stubs = cachedPlans
.filter(p => p.source === 'bulk-create')
.map((p, i) => ({
action_plan_id: 'pending-' + hostId + '-' + i,
plan_type: p.plan_type || 'unknown',
commit_date: p.commit_date || '',
status: 'pending',
_localPending: true,
created_at: p.created_at || '',
}));
if (stubs.length > 0) {
setPlans(stubs);
return;
}
}
}
} catch (_) { /* ignore cache fallback errors */ }
}
// Merge: keep local pending plans that aren't yet confirmed by Atlas // Merge: keep local pending plans that aren't yet confirmed by Atlas
setPlans(prev => { setPlans(prev => {
const localPending = prev.filter(p => p._localPending); const localPending = prev.filter(p => p._localPending);

View File

@@ -180,7 +180,8 @@ export default function UserManagement({ onClose }) {
username: '', username: '',
email: '', email: '',
password: '', password: '',
group: 'Read_Only' group: 'Read_Only',
bu_teams: ''
}); });
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState(''); const [formSuccess, setFormSuccess] = useState('');
@@ -240,7 +241,7 @@ export default function UserManagement({ onClose }) {
setTimeout(() => { setTimeout(() => {
setShowAddUser(false); setShowAddUser(false);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', group: 'Read_Only' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
setFormSuccess(''); setFormSuccess('');
}, 1500); }, 1500);
} catch (err) { } catch (err) {
@@ -278,7 +279,8 @@ export default function UserManagement({ onClose }) {
username: user.username, username: user.username,
email: user.email, email: user.email,
password: '', password: '',
group: user.group group: user.group,
bu_teams: user.bu_teams || ''
}); });
setShowAddUser(true); setShowAddUser(true);
setFormError(''); setFormError('');
@@ -361,7 +363,7 @@ export default function UserManagement({ onClose }) {
onClick={() => { onClick={() => {
setShowAddUser(true); setShowAddUser(true);
setEditingUser(null); setEditingUser(null);
setFormData({ username: '', email: '', password: '', group: 'Read_Only' }); setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
setFormError(''); setFormError('');
setFormSuccess(''); setFormSuccess('');
}} }}
@@ -482,6 +484,50 @@ export default function UserManagement({ onClose }) {
</div> </div>
</div> </div>
{/* BU Teams assignment */}
<div style={{ marginTop: '1rem' }}>
<label style={styles.label}>BU Teams</label>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '0.5rem',
padding: '0.75rem',
background: 'rgba(30,41,59,0.6)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.5rem',
}}>
{['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'].map(team => {
const currentTeams = formData.bu_teams ? formData.bu_teams.split(',').filter(Boolean) : [];
const isChecked = currentTeams.includes(team);
return (
<label key={team} style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
cursor: 'pointer', fontSize: '0.8rem', fontFamily: 'monospace',
color: isChecked ? '#38BDF8' : '#94A3B8',
padding: '0.25rem 0.5rem', borderRadius: '0.25rem',
background: isChecked ? 'rgba(14,165,233,0.1)' : 'transparent',
border: isChecked ? '1px solid rgba(14,165,233,0.3)' : '1px solid transparent',
transition: 'all 0.15s',
}}>
<input
type="checkbox"
checked={isChecked}
onChange={() => {
const updated = isChecked
? currentTeams.filter(t => t !== team)
: [...currentTeams, team];
setFormData({ ...formData, bu_teams: updated.join(',') });
}}
style={{ accentColor: '#0EA5E9' }}
/>
{team}
</label>
);
})}
</div>
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
Determines which BU data the user sees on Reporting and Compliance pages.
</p>
</div>
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}> <div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
<button type="submit" style={styles.primaryBtn} <button type="submit" style={styles.primaryBtn}
onMouseEnter={e => { onMouseEnter={e => {
@@ -523,6 +569,7 @@ export default function UserManagement({ onClose }) {
<tr> <tr>
<th style={styles.th}>User</th> <th style={styles.th}>User</th>
<th style={styles.th}>Group</th> <th style={styles.th}>Group</th>
<th style={styles.th}>Teams</th>
<th style={styles.th}>Status</th> <th style={styles.th}>Status</th>
<th style={styles.th}>Last Login</th> <th style={styles.th}>Last Login</th>
<th style={styles.thRight}>Actions</th> <th style={styles.thRight}>Actions</th>
@@ -547,6 +594,25 @@ export default function UserManagement({ onClose }) {
{user.group ? user.group.replace('_', ' ') : 'Read Only'} {user.group ? user.group.replace('_', ' ') : 'Read Only'}
</span> </span>
</td> </td>
<td style={styles.td}>
{(user.teams && user.teams.length > 0) ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{user.teams.map(t => (
<span key={t} style={{
fontSize: '0.65rem', fontFamily: 'monospace',
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
background: 'rgba(14,165,233,0.1)',
border: '1px solid rgba(14,165,233,0.25)',
color: '#7DD3FC',
}}>{t}</span>
))}
</div>
) : (
<span style={{ fontSize: '0.7rem', color: '#F59E0B', fontStyle: 'italic' }}>
No teams
</span>
)}
</td>
<td style={styles.td}> <td style={styles.td}>
<button <button
onClick={() => handleToggleActive(user)} onClick={() => handleToggleActive(user)}

View File

@@ -9,7 +9,6 @@ import metricDefinitionsRaw from '../../data/metricDefinitions.json';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6'; const TEAL = '#14B8A6';
const TEAMS = ['STEAM', 'ACCESS-ENG'];
// Build definitions lookup map once at module level // Build definitions lookup map once at module level
const METRIC_DEFINITIONS = {}; const METRIC_DEFINITIONS = {};
@@ -246,9 +245,10 @@ function SeenBadge({ count }) {
// Main Page // Main Page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function CompliancePage({ onNavigate }) { export default function CompliancePage({ onNavigate }) {
const { canWrite, isAdmin } = useAuth(); const { canWrite, isAdmin, getAvailableTeams, adminScope } = useAuth();
const [activeTeam, setActiveTeam] = useState('STEAM'); const availableTeams = getAvailableTeams();
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
const [activeTab, setActiveTab] = useState('active'); const [activeTab, setActiveTab] = useState('active');
const [metricFilter, setMetricFilter] = useState(null); const [metricFilter, setMetricFilter] = useState(null);
const [hostSearch, setHostSearch] = useState(''); const [hostSearch, setHostSearch] = useState('');
@@ -298,6 +298,14 @@ export default function CompliancePage({ onNavigate }) {
fetchDevices(activeTeam, activeTab); fetchDevices(activeTeam, activeTab);
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
// When admin scope changes, reset to first available team
useEffect(() => {
const teams = getAvailableTeams();
if (teams.length > 0 && !teams.includes(activeTeam)) {
setActiveTeam(teams[0]);
}
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
setMetricFilter(null); setMetricFilter(null);
fetchDevices(activeTeam, activeTab); fetchDevices(activeTeam, activeTab);
@@ -419,8 +427,19 @@ export default function CompliancePage({ onNavigate }) {
</div> </div>
{/* ── Team tabs ────────────────────────────────────────────── */} {/* ── Team tabs ────────────────────────────────────────────── */}
{availableTeams.length === 0 && !isAdmin() ? (
<div style={{
padding: '1.5rem', marginBottom: '1.5rem',
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
background: 'rgba(245, 158, 11, 0.05)',
fontFamily: 'monospace', fontSize: '0.8rem', color: '#F59E0B',
textAlign: 'center'
}}>
No BU teams assigned to your account. Contact an admin to configure your team access.
</div>
) : (
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
{TEAMS.map(team => { {availableTeams.map(team => {
const isActive = activeTeam === team; const isActive = activeTeam === team;
return ( return (
<button key={team} onClick={() => setActiveTeam(team)} <button key={team} onClick={() => setActiveTeam(team)}
@@ -441,6 +460,7 @@ export default function CompliancePage({ onNavigate }) {
); );
})} })}
</div> </div>
)}
{/* ── Metric health cards ──────────────────────────────────── */} {/* ── Metric health cards ──────────────────────────────────── */}
{families.length > 0 ? ( {families.length > 0 ? (

View File

@@ -97,8 +97,11 @@ function findingRow(f) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// API fetchers // API fetchers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function fetchFindings() { async function fetchFindings(teamsParam) {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); const url = teamsParam
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`); if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
const data = await res.json(); const data = await res.json();
return data.findings || []; return data.findings || [];
@@ -129,8 +132,8 @@ async function fetchAtlasStatus() {
return res.json(); return res.json();
} }
async function fetchAtlasAndFindings() { async function fetchAtlasAndFindings(teamsParam) {
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings()]); const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.) // Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
const hostMap = {}; const hostMap = {};
findings.forEach(f => { findings.forEach(f => {
@@ -244,7 +247,8 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page // Main page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ExportsPage() { export default function ExportsPage() {
const { canExport } = useAuth(); const { canExport, getActiveTeamsParam } = useAuth();
const teamsParam = getActiveTeamsParam();
const [loading, setLoading] = useState(null); const [loading, setLoading] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState(''); const [cveStatus, setCveStatus] = useState('');
@@ -266,32 +270,35 @@ export default function ExportsPage() {
// ---- Card 1: Ivanti Findings ---- // ---- Card 1: Ivanti Findings ----
const exportFullFindings = () => run('ivanti-full', async () => { const exportFullFindings = () => run('ivanti-full', async () => {
const findings = await fetchFindings(); const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
toXLSX( toXLSX(
[FINDING_HEADERS, ...findings.map(findingRow)], [FINDING_HEADERS, ...findings.map(findingRow)],
'All Findings', 'All Findings',
`findings-full-${dateStr()}.xlsx`, `findings-full-${scopeLabel}-${dateStr()}.xlsx`,
); );
}); });
const exportPending = () => run('ivanti-pending', async () => { const exportPending = () => run('ivanti-pending', async () => {
const findings = await fetchFindings(); const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow); const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`); toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
}); });
const exportOverdue = () => run('ivanti-overdue', async () => { const exportOverdue = () => run('ivanti-overdue', async () => {
const findings = await fetchFindings(); const findings = await fetchFindings(teamsParam);
const scopeLabel = teamsParam || 'ALL';
const today = dateStr(); const today = dateStr();
const rows = findings.filter(f => { const rows = findings.filter(f => {
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false; if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE'; return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
}).map(findingRow); }).map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`); toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
}); });
const exportByBU = () => run('ivanti-bu', async () => { const exportByBU = () => run('ivanti-bu', async () => {
const findings = await fetchFindings(); const findings = await fetchFindings(teamsParam);
const groups = {}; const groups = {};
findings.forEach(f => { findings.forEach(f => {
const bu = f.buOwnership || 'Unknown'; const bu = f.buOwnership || 'Unknown';
@@ -308,7 +315,7 @@ export default function ExportsPage() {
// ---- Card 2: FP Workflow Summary ---- // ---- Card 2: FP Workflow Summary ----
const exportFPSummary = () => run('fp-summary', async () => { const exportFPSummary = () => run('fp-summary', async () => {
const findings = await fetchFindings(); const findings = await fetchFindings(teamsParam);
const fpMap = {}; const fpMap = {};
findings.forEach(f => { findings.forEach(f => {
if (!f.workflow?.id) return; if (!f.workflow?.id) return;
@@ -383,20 +390,20 @@ export default function ExportsPage() {
} }
const exportAtlasStatus = () => run('atlas-status', async () => { const exportAtlasStatus = () => run('atlas-status', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(); const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id])); const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`); toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
}); });
const exportAtlasGaps = () => run('atlas-gaps', async () => { const exportAtlasGaps = () => run('atlas-gaps', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(); const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const gaps = atlasRows.filter(a => !a.has_action_plan); const gaps = atlasRows.filter(a => !a.has_action_plan);
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id])); const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`); toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
}); });
const exportAtlasFull = () => run('atlas-full', async () => { const exportAtlasFull = () => run('atlas-full', async () => {
const { atlasRows, hostMap } = await fetchAtlasAndFindings(); const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
const withPlans = atlasRows.filter(a => a.has_action_plan); const withPlans = atlasRows.filter(a => a.has_action_plan);
const withoutPlans = atlasRows.filter(a => !a.has_action_plan); const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
const sheets = [ const sheets = [

View File

@@ -188,7 +188,7 @@ function extractDate(ts) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main component // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function IvantiCountsChart() { export default function IvantiCountsChart({ teamsParam }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
@@ -199,8 +199,11 @@ export default function IvantiCountsChart() {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const historyUrl = teamsParam
? `${API_BASE}/ivanti/findings/counts/history?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts/history`;
const [countsRes, anomalyRes] = await Promise.all([ const [countsRes, anomalyRes] = await Promise.all([
fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' }), fetch(historyUrl, { credentials: 'include' }),
fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }), fetch(`${API_BASE}/ivanti/findings/anomaly/history`, { credentials: 'include' }),
]); ]);
if (!cancelled) { if (!cancelled) {
@@ -218,7 +221,7 @@ export default function IvantiCountsChart() {
}; };
load(); load();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, [teamsParam]);
const chartData = useMemo( const chartData = useMemo(
() => history.map(r => ({ ...r, date: fmtDate(r.date) })), () => history.map(r => ({ ...r, date: fmtDate(r.date) })),

View File

@@ -4492,7 +4492,11 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const seen = new Map(); const seen = new Map();
for (const f of selectedFindings) { for (const f of selectedFindings) {
if (f.hostId && !seen.has(f.hostId)) { if (f.hostId && !seen.has(f.hostId)) {
seen.set(f.hostId, { hostId: f.hostId, hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId) }); seen.set(f.hostId, {
hostId: f.hostId,
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
findingId: f.id ? Number(f.id) : null,
});
} }
} }
return [...seen.values()]; return [...seen.values()];
@@ -4575,7 +4579,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
if (!commitDate) { setError('Commit date is required'); return; } if (!commitDate) { setError('Commit date is required'); return; }
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; } if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
const needsQualys = NEEDS_QUALYS.has(planType); const needsQualys = NEEDS_QUALYS.has(planType);
if (needsQualys && selectedQualys.size === 0) { if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`); setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
return; return;
} }
@@ -4583,12 +4587,19 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
const qualysIds = needsQualys ? [...selectedQualys] : [null]; // If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
const results = []; const results = [];
for (const qid of qualysIds) { for (const qid of qualysIds) {
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate }; const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
if (qid) body.qualys_id = qid; if (qid) body.qualys_id = qid;
// When no qualys_id is available, include the first finding ID per host
// so Atlas can associate the plan with a specific vulnerability
if (!qid && needsQualys) {
const firstWithFinding = hostEntries.find(h => h.findingId);
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
}
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim(); if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
if (archerExc.trim()) body.archer_exc = archerExc.trim(); if (archerExc.trim()) body.archer_exc = archerExc.trim();
@@ -4796,7 +4807,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && ( {!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}> <div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
No vulnerabilities found in Atlas for these hosts No vulnerabilities found in Atlas for these hosts Qualys ID will be omitted
</div> </div>
)} )}
@@ -4908,7 +4919,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
// Main ReportingPage // Main ReportingPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) { export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const { canWrite } = useAuth(); const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = useAuth();
const [findings, setFindings] = useState([]); const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null); const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null); const [syncedAt, setSyncedAt] = useState(null);
@@ -5041,7 +5052,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const fetchCounts = async () => { const fetchCounts = async () => {
setCountsLoading(true); setCountsLoading(true);
try { try {
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' }); // Fetch counts from server — Postgres provides per-BU open+closed counts
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts`;
const res = await fetch(url, { credentials: 'include' });
const data = await res.json(); const data = await res.json();
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
} catch (e) { } catch (e) {
@@ -5127,6 +5143,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const fetchFindings = async () => { const fetchFindings = async () => {
setLoading(true); setLoading(true);
try { try {
// Always fetch ALL findings — filtering happens client-side for instant scope switching
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
@@ -5169,6 +5186,19 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchCardStatus(); fetchCardStatus();
}, []); // eslint-disable-line }, []); // eslint-disable-line
// Re-fetch counts when admin scope changes (per-BU counts from Postgres)
// Silent fetch — no loading spinner, just update the numbers
useEffect(() => {
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/findings/counts`;
fetch(url, { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 }); })
.catch(() => {});
}, [adminScope]); // eslint-disable-line
// Set/clear a single column filter // Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => { const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => { setColumnFilters((prev) => {
@@ -5181,11 +5211,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}); });
}, []); }, []);
// Scope findings by selected BU teams (client-side filtering for instant switching)
const scopedFindings = useMemo(() => {
const teamsParam = getActiveTeamsParam();
if (!teamsParam) return findings; // no filter = show all
const teams = teamsParam.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
if (teams.length === 0) return findings;
return findings.filter(f =>
teams.some(t => (f.buOwnership || '').toUpperCase().includes(t))
);
}, [findings, adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
// Visible findings — hidden rows removed before any other filtering // Visible findings — hidden rows removed before any other filtering
const visibleFindings = useMemo(() => { const visibleFindings = useMemo(() => {
if (hiddenRowIds.size === 0) return findings; if (hiddenRowIds.size === 0) return scopedFindings;
return findings.filter(f => !hiddenRowIds.has(String(f.id))); return scopedFindings.filter(f => !hiddenRowIds.has(String(f.id)));
}, [findings, hiddenRowIds]); }, [scopedFindings, hiddenRowIds]);
// Apply all active filters to produce the visible row set // Apply all active filters to produce the visible row set
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -5638,7 +5679,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
<div role="tabpanel"> <div role="tabpanel">
{metricsTab === 'ivanti' && ( {metricsTab === 'ivanti' && (
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{/* Open vs Closed donut */} {/* Open vs Closed donut — per-BU counts from Postgres */}
<div style={{ flex: '0 0 auto' }}> <div style={{ flex: '0 0 auto' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}> <div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
Open vs Closed Open vs Closed
@@ -5753,7 +5794,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
Panel 1.5 — Open vs Closed trend over time Panel 1.5 — Open vs Closed trend over time
---------------------------------------------------------------- */} ---------------------------------------------------------------- */}
{metricsTab === 'ivanti' && <AnomalyBanner />} {metricsTab === 'ivanti' && <AnomalyBanner />}
{metricsTab === 'ivanti' && <IvantiCountsChart />} {metricsTab === 'ivanti' && <IvantiCountsChart teamsParam={getActiveTeamsParam()} />}
{/* ---------------------------------------------------------------- {/* ----------------------------------------------------------------
Panel 2 — Findings table Panel 2 — Findings table

View File

@@ -2,13 +2,39 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// Known BU teams — must match backend helpers/teams.js
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
const AuthContext = createContext(null); const AuthContext = createContext(null);
// Load admin scope from localStorage — returns array of selected teams
function loadAdminScope() {
try {
const saved = localStorage.getItem('admin_bu_scope');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) return parsed;
}
} catch { /* ignore */ }
// Default: null means "not yet initialized" — will be set to user's teams on first load
return null;
}
function saveAdminScope(teams) {
try {
localStorage.setItem('admin_bu_scope', JSON.stringify(teams));
} catch { /* ignore */ }
}
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Admin scope — array of currently selected teams for filtering
// null = not initialized yet (will default to user's teams after login)
const [adminScope, setAdminScope] = useState(loadAdminScope);
// Check if user is authenticated on mount // Check if user is authenticated on mount
const checkAuth = useCallback(async () => { const checkAuth = useCallback(async () => {
try { try {
@@ -19,6 +45,12 @@ export function AuthProvider({ children }) {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setUser(data.user); setUser(data.user);
// Initialize admin scope to user's teams if not yet set
if (adminScope === null && data.user?.teams?.length > 0) {
const initial = data.user.teams;
setAdminScope(initial);
saveAdminScope(initial);
}
} else { } else {
setUser(null); setUser(null);
} }
@@ -28,7 +60,7 @@ export function AuthProvider({ children }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
checkAuth(); checkAuth();
@@ -52,6 +84,12 @@ export function AuthProvider({ children }) {
} }
setUser(data.user); setUser(data.user);
// Initialize scope to user's teams on login
if (data.user?.teams?.length > 0 && adminScope === null) {
const initial = data.user.teams;
setAdminScope(initial);
saveAdminScope(initial);
}
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -79,7 +117,6 @@ export function AuthProvider({ children }) {
const canWrite = () => isInGroup('Admin', 'Standard_User'); const canWrite = () => isInGroup('Admin', 'Standard_User');
// Check if user can delete a resource // Check if user can delete a resource
// Admin: always true; Standard_User: only if they own the resource; others: false
const canDelete = (resource) => { const canDelete = (resource) => {
if (!user) return false; if (!user) return false;
if (isInGroup('Admin')) return true; if (isInGroup('Admin')) return true;
@@ -93,6 +130,61 @@ export function AuthProvider({ children }) {
// Check if user is admin // Check if user is admin
const isAdmin = () => isInGroup('Admin'); const isAdmin = () => isInGroup('Admin');
// -----------------------------------------------------------------------
// Multi-BU tenancy helpers
// -----------------------------------------------------------------------
// Whether the user has any BU teams assigned
const hasTeams = () => (user?.teams?.length ?? 0) > 0;
// Whether the user is a member of a specific team
const isTeamMember = (team) => {
if (!user) return false;
if (isInGroup('Admin')) {
// Admin: check against current scope selection
const scope = adminScope || [];
return scope.length === 0 || scope.includes(team);
}
return (user.teams || []).includes(team);
};
// Set the admin scope to a specific set of teams
const setAdminScopeTeams = (teams) => {
setAdminScope(teams);
saveAdminScope(teams);
};
// Returns the comma-joined teams string for API query params.
// Empty string means "no filter" (show all).
const getActiveTeamsParam = () => {
if (!user) return '';
if (isInGroup('Admin')) {
const scope = adminScope || [];
// If all teams selected or empty, no filter
if (scope.length === 0 || scope.length === KNOWN_TEAMS.length) return '';
return scope.join(',');
}
// Non-admin: always use their assigned teams
const teams = user.teams || [];
return teams.join(',');
};
// Returns the list of teams available for UI selectors (compliance team picker, etc.)
const getAvailableTeams = () => {
if (!user) return [];
if (isInGroup('Admin')) {
const scope = adminScope || [];
// If all selected or empty, show all known teams
if (scope.length === 0 || scope.length === KNOWN_TEAMS.length) return KNOWN_TEAMS;
return scope;
}
return user.teams || [];
};
const value = { const value = {
user, user,
loading, loading,
@@ -105,7 +197,15 @@ export function AuthProvider({ children }) {
canDelete, canDelete,
canExport, canExport,
isAdmin, isAdmin,
isAuthenticated: !!user isAuthenticated: !!user,
// Multi-BU tenancy
hasTeams,
isTeamMember,
adminScope,
setAdminScopeTeams,
getActiveTeamsParam,
getAvailableTeams,
KNOWN_TEAMS,
}; };
return ( return (

View File

@@ -2,15 +2,13 @@
"name": "cve-dashboard", "name": "cve-dashboard",
"version": "1.0.0", "version": "1.0.0",
"description": "STEAM Security Dashboard — vulnerability management for NTS-AEO", "description": "STEAM Security Dashboard — vulnerability management for NTS-AEO",
"author": "Jordan Ramos <jordan.ramos@spectrum.com>", "author": "",
"license": "UNLICENSED", "license": "ISC",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -19,6 +17,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"pg": "^8.20.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {

118
scripts/deploy-postgres.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/bash
# =============================================================================
# deploy-postgres.sh — One-time deployment script for Postgres migration
# =============================================================================
# Run this ONCE on a fresh server after pulling the feature/multi-tenancy code.
# Prerequisites: Docker installed, Node.js 18+, npm
#
# What this does:
# 1. Starts the Postgres container (docker compose)
# 2. Waits for Postgres to be ready
# 3. Runs the schema DDL
# 4. Installs npm dependencies (adds 'pg' package)
# 5. Runs the data migration script (SQLite → Postgres)
# 6. Rebuilds the frontend
# 7. Prints next steps
#
# Usage:
# chmod +x scripts/deploy-postgres.sh
# ./scripts/deploy-postgres.sh
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ CVE Dashboard — Postgres Deployment Script ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
# Check prerequisites
command -v docker >/dev/null 2>&1 || { echo "ERROR: Docker is not installed. Install with: apt install -y docker.io"; exit 1; }
command -v node >/dev/null 2>&1 || { echo "ERROR: Node.js is not installed."; exit 1; }
command -v psql >/dev/null 2>&1 || { echo "WARNING: psql not found. Installing postgresql-client..."; apt install -y postgresql-client >/dev/null 2>&1 || true; }
# Check if .env has DATABASE_URL
if ! grep -q "DATABASE_URL" backend/.env 2>/dev/null; then
echo "Adding DATABASE_URL to backend/.env..."
echo "" >> backend/.env
echo "# PostgreSQL (Docker container steam-postgres on port 5433)" >> backend/.env
echo "DATABASE_URL=postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard" >> backend/.env
echo "✓ DATABASE_URL added to .env"
else
echo "✓ DATABASE_URL already in .env"
fi
# Step 1: Start Postgres container
echo ""
echo "── Step 1: Starting Postgres container ──"
if docker ps --format '{{.Names}}' | grep -q steam-postgres; then
echo "✓ steam-postgres container already running"
else
docker compose up -d
echo "✓ Container started"
fi
# Step 2: Wait for Postgres to be ready
echo ""
echo "── Step 2: Waiting for Postgres to be ready ──"
for i in $(seq 1 30); do
if PGPASSWORD=sV4xmC9xAUCFop0ypxMVS056QgPqGrX psql -h localhost -p 5433 -U steam -d cve_dashboard -c "SELECT 1" >/dev/null 2>&1; then
echo "✓ Postgres is ready"
break
fi
if [ $i -eq 30 ]; then
echo "ERROR: Postgres did not become ready in 30 seconds"
exit 1
fi
sleep 1
done
# Step 3: Run schema
echo ""
echo "── Step 3: Creating schema ──"
PGPASSWORD=sV4xmC9xAUCFop0ypxMVS056QgPqGrX psql -h localhost -p 5433 -U steam -d cve_dashboard -f backend/db-schema.sql >/dev/null 2>&1
echo "✓ Schema created"
# Step 4: Install dependencies
echo ""
echo "── Step 4: Installing npm dependencies ──"
cd backend && npm install --production >/dev/null 2>&1 && cd ..
echo "✓ Dependencies installed"
# Step 5: Run data migration
echo ""
echo "── Step 5: Running data migration (SQLite → Postgres) ──"
if [ -f backend/cve_database.db ]; then
node backend/scripts/migrate-to-postgres.js
else
echo "⚠ No SQLite database found — skipping migration (fresh install)"
fi
# Step 6: Build frontend
echo ""
echo "── Step 6: Building frontend ──"
cd frontend && npm install >/dev/null 2>&1 && npm run build >/dev/null 2>&1 && cd ..
echo "✓ Frontend built"
# Done
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ Deployment complete! ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
echo "Next steps:"
echo " 1. Stop the old backend: kill \$(lsof -t -i:3001)"
echo " 2. Start the new backend: node backend/server.js"
echo " 3. Verify: curl http://localhost:3001/api/auth/me"
echo ""
echo "Rollback (if needed):"
echo " 1. Stop the new backend"
echo " 2. Remove DATABASE_URL from backend/.env"
echo " 3. git checkout master~1 (go back one commit)"
echo " 4. Restart the old backend"
echo ""