16 KiB
Design Document: Batch Finding Disposition
Overview
This feature adds multi-select capability to the Vulnerability Triage page's findings table, enabling engineers to select multiple findings and add them all to the Ivanti Queue in a single operation. The current flow requires clicking each finding individually, configuring a popover, and submitting one at a time — this design replaces that with a batch selection toolbar and a bulk-add API endpoint while preserving the existing single-select popover for one-off additions.
The design touches three layers:
- A new
POST /api/ivanti/todo-queue/batchbackend endpoint that accepts an array of findings in a single transactional insert - Frontend multi-select state management (selection set, shift-click range select, select-all)
- A sticky Selection Toolbar component with workflow type toggles, vendor input, and batch submit
Architecture
The feature extends the existing Ivanti Queue subsystem without introducing new services or tables. The ivanti_todo_queue table schema is unchanged — batch add simply inserts multiple rows in a single SQLite transaction.
flowchart TD
subgraph Frontend ["Frontend (ReportingPage.js)"]
CB[Row Checkboxes] --> SS[Selection State<br/>Set of finding IDs]
SS --> ST[Selection Toolbar]
ST -->|"Add to Queue"| BA[Batch API Call]
CB -->|"No selection + click"| PO[AddToQueuePopover<br/>existing single-add]
end
subgraph Backend ["Backend (ivantiTodoQueue.js)"]
BA -->|"POST /batch"| BH[Batch Handler]
BH -->|"BEGIN TRANSACTION"| DB[(ivanti_todo_queue)]
BH -->|"logAudit()"| AL[(audit_logs)]
PO -->|"POST /"| SH[Single Handler<br/>existing]
SH --> DB
end
Key Design Decisions
-
No new database table or migration — batch insert reuses the existing
ivanti_todo_queueschema. Each finding becomes its own row, identical to what the single-add endpoint creates. -
SQLite transaction for atomicity — all findings in a batch are inserted inside
db.serialize()withBEGIN TRANSACTION/COMMIT. If any insert fails, the entire batch is rolled back. This satisfies the all-or-nothing requirement (Req 3.7, 3.8, 3.11). -
Selection state lives in the VulnerabilityTriagePage component — a
Set<string>of finding IDs managed viauseState. This keeps the selection co-located with the existingfindings,sorted,filtered, andqueueItemsstate. No new context or global store needed. -
Dual-mode checkbox behavior — when no findings are selected, clicking a checkbox opens the existing
AddToQueuePopover(preserving the single-select flow per Req 5). Once one or more findings are selected, subsequent checkbox clicks toggle selection instead. This is the simplest UX that satisfies both Req 1 and Req 5. -
Selection Toolbar as inline sticky bar — rendered between the table header controls and the
<table>element, usingposition: stickyto stay visible during scroll. This avoids portal complexity and keeps the toolbar visually anchored to the table. -
200-item batch limit — prevents oversized payloads and keeps SQLite transaction time reasonable. The findings table typically has 200-800 rows, so this covers most realistic batch sizes.
Components and Interfaces
Backend
POST /api/ivanti/todo-queue/batch
Added to the existing createIvantiTodoQueueRouter factory in backend/routes/ivantiTodoQueue.js.
Request body:
{
"findings": [
{
"finding_id": "FID-12345",
"finding_title": "OpenSSL vulnerability",
"cves": ["CVE-2024-0001"],
"ip_address": "10.0.1.50"
}
],
"workflow_type": "FP",
"vendor": "Juniper"
}
Validation rules:
findings— array, 1–200 items- Each item:
finding_idrequired, non-empty string;finding_title,cves,ip_addressoptional workflow_type— must beFP,Archer, orCARDvendor— required non-empty string (≤200 chars) for FP/Archer; ignored for CARD- If any finding fails validation, reject entire batch with 400
Auth: requireAuth(db), requireGroup('Admin', 'Standard_User')
Response (201):
{
"items": [
{
"id": 42,
"user_id": 1,
"finding_id": "FID-12345",
"finding_title": "OpenSSL vulnerability",
"cves_json": "[\"CVE-2024-0001\"]",
"ip_address": "10.0.1.50",
"vendor": "Juniper",
"workflow_type": "FP",
"status": "pending",
"created_at": "2025-01-15 12:00:00",
"updated_at": "2025-01-15 12:00:00",
"cves": ["CVE-2024-0001"]
}
]
}
Error responses:
400— validation failure (descriptive message)401— not authenticated403— insufficient permissions500— database transaction failure (all inserts rolled back)
Frontend
Selection State (in VulnerabilityTriagePage)
New state variables added to the main component:
const [selectedIds, setSelectedIds] = useState(new Set()); // Set<string> of finding IDs
const [lastClickedId, setLastClickedId] = useState(null); // for shift-click range select
const [batchSubmitting, setBatchSubmitting] = useState(false); // loading state
const [batchError, setBatchError] = useState(null); // error message from failed batch
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState('');
Checkbox Click Logic
onClick(finding, event):
if finding is already queued → return (no-op)
if selectedIds.size === 0 AND not shift-click:
→ open AddToQueuePopover (existing single-select flow)
else:
if shift-click AND lastClickedId exists:
→ range-select all visible findings between lastClickedId and finding.id
else:
→ toggle finding.id in selectedIds
set lastClickedId = finding.id
SelectionToolbar Component
Rendered inline above the table when selectedIds.size > 0. Contains:
- Selected count badge
- "Clear Selection" button
- Workflow type toggle buttons (FP / Archer / CARD) with existing color scheme
- Vendor text input (hidden when CARD selected)
- "Add to Queue" submit button (disabled until valid)
- Error message display area
Selection Persistence Across Filters
When columnFilters, actionFilter, or excFilter change, the selection set is pruned to only include IDs that remain in the filtered array. This is done via a useEffect that intersects selectedIds with the current filtered finding IDs.
Select All / Deselect All
The checkbox column header renders a "Select All" control when selectedIds.size > 0 or as a standard header otherwise. Clicking it:
- If not all visible non-queued findings are selected → selects all visible non-queued findings
- If all are already selected → deselects all
Data Models
Database Schema (unchanged)
The ivanti_todo_queue table is reused as-is:
CREATE TABLE ivanti_todo_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
ip_address TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Each batch-added finding creates one row, identical to single-add. The vendor and workflow_type are shared across all findings in a batch (set once in the toolbar).
API Request Schema
BatchAddRequest {
findings: Array<{
finding_id: string (required, non-empty, trimmed)
finding_title: string | null (max 500 chars)
cves: string[] | null
ip_address: string | null (max 64 chars)
}> (1–200 items)
workflow_type: "FP" | "Archer" | "CARD"
vendor: string (required for FP/Archer, ≤200 chars; empty/absent for CARD)
}
Frontend State Shape
Selection State:
selectedIds: Set<string> — finding IDs currently selected
lastClickedId: string | null — last checkbox clicked (for shift-range)
batchSubmitting: boolean — true while POST /batch in flight
batchError: string | null — error message from last failed batch
batchWorkflowType: "FP" | "Archer" | "CARD"
batchVendor: string
Correctness Properties
A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
Property 1: Selection pruning preserves only visible findings
For any set of selected finding IDs and any set of currently visible (filtered) finding IDs, pruning the selection after a filter change should produce exactly the intersection of the two sets — every ID in the result is both selected and visible, and no visible selected ID is lost.
Validates: Requirements 1.4
Property 2: Select-all produces the complete visible non-queued set
For any list of visible findings and any set of queued finding IDs, the select-all operation should produce a set containing exactly the IDs of visible findings that are not in the queued set — no queued findings included, no non-queued visible findings omitted.
Validates: Requirements 1.6
Property 3: Submit button enabled state matches validation rule
For any workflow type (FP, Archer, CARD) and any vendor string, the "Add to Queue" button should be enabled if and only if the workflow type is CARD, or the vendor string trimmed is non-empty. No other combination should enable the button.
Validates: Requirements 2.7
Property 4: Batch size validation accepts only 1–200 items
For any integer N representing the number of findings in a batch request, the endpoint should accept the request (assuming all other fields are valid) if and only if 1 ≤ N ≤ 200. Arrays of size 0 or greater than 200 should be rejected with a 400 response.
Validates: Requirements 3.2
Property 5: Vendor validation is conditional on workflow type
For any workflow type and any vendor string, the batch endpoint should require a non-empty vendor of 200 characters or fewer when workflow_type is FP or Archer, and should accept any vendor value (including empty or absent) when workflow_type is CARD.
Validates: Requirements 3.5, 3.6
Property 6: One invalid finding rejects the entire batch
For any valid batch of findings, if exactly one finding is replaced with an invalid finding (empty finding_id, missing finding_id, or non-string finding_id) at any position in the array, the entire batch should be rejected with a 400 response and zero rows should be inserted.
Validates: Requirements 3.3, 3.8
Property 7: Successful batch response matches request
For any valid batch request of N findings, the 201 response should contain exactly N items, each with a unique numeric id, and the set of finding_id values in the response should equal the set of finding_id values in the request.
Validates: Requirements 3.9
Property 8: Shift-click range select covers exactly the between range
For any sorted list of visible findings, any last-clicked index, and any current-click index, the shift-click range select should produce a set containing exactly the non-queued findings between those two indices (inclusive), regardless of which index is larger.
Validates: Requirements 6.1
Error Handling
Backend Errors
| Scenario | Response | Behavior |
|---|---|---|
| Empty findings array or > 200 items | 400 | { error: "findings array must contain 1-200 items." } |
| Any finding missing/empty finding_id | 400 | { error: "Each finding must have a non-empty finding_id string." } |
| Invalid workflow_type | 400 | { error: "workflow_type must be FP, Archer, or CARD." } |
| Missing vendor for FP/Archer | 400 | { error: "vendor is required for FP and Archer workflows." } |
| Vendor exceeds 200 chars | 400 | { error: "vendor must be under 200 chars." } |
| Not authenticated | 401 | Standard auth middleware response |
| Insufficient permissions (Read_Only) | 403 | Standard group middleware response |
| SQLite transaction failure | 500 | Transaction rolled back, { error: "Internal server error." } |
Frontend Errors
| Scenario | Behavior |
|---|---|
| Batch POST returns 4xx/5xx | Display error message in Selection Toolbar, keep selection intact |
| Network failure during batch POST | Display "Network error — please try again" in toolbar, keep selection |
| Batch POST timeout | Same as network failure handling |
Edge Cases
- Duplicate finding_ids in batch: Allowed — the same finding could appear on multiple hosts. The backend does not enforce uniqueness on finding_id within a batch.
- Finding already in queue: The frontend prevents selecting already-queued findings (checkbox is disabled), so duplicates should not reach the API. No server-side duplicate check is added to keep the endpoint simple.
- Concurrent batch submissions: The SQLite transaction serializes writes. If two users submit overlapping batches, both succeed independently (each user has their own queue scoped by user_id).
- Selection of 0 findings: The "Add to Queue" button is only rendered when selectedIds.size > 0, so this state cannot be reached through the UI. The backend still validates for it.
Testing Strategy
Unit Tests
Focus on specific examples and edge cases:
- Backend validation: Test each validation rule with concrete valid/invalid inputs (empty array, 201 items, missing finding_id, invalid workflow_type, vendor edge cases)
- Transaction rollback: Mock a database error mid-insert, verify no rows are committed
- Frontend checkbox dual-mode: Test that clicking with empty selection opens popover, clicking with existing selection toggles selection
- Toolbar visibility: Test toolbar appears/disappears based on selection state
- Clear selection: Test that clear button empties selection
- Escape key: Test that Escape clears selection
- Select-all toggle: Test select-all and deselect-all behavior
- Queue panel update: Test that successful batch updates queueItems state
Property-Based Tests
Using fast-check for JavaScript property-based testing.
Each property test runs a minimum of 100 iterations with randomly generated inputs. Tests are tagged with their corresponding design property.
| Property | What's Generated | What's Verified |
|---|---|---|
| Property 1: Selection pruning | Random sets of selected IDs and filtered IDs | Result = intersection of both sets |
| Property 2: Select-all | Random finding lists and queued ID sets | Result = visible IDs minus queued IDs |
| Property 3: Submit enabled | Random workflow types and vendor strings | Enabled iff CARD or non-empty vendor |
| Property 4: Batch size | Random integers 0–300 | Accepted iff 1 ≤ N ≤ 200 |
| Property 5: Vendor validation | Random workflow types and vendor strings (0–300 chars) | Conditional acceptance rule |
| Property 6: Invalid finding rejection | Valid batches with one injected invalid item | Entire batch rejected, 0 rows inserted |
| Property 7: Response shape | Valid batches of 1–50 findings | Response count matches, IDs match |
| Property 8: Range select | Random sorted lists and two index positions | Correct range of non-queued findings |
Integration Tests
- End-to-end batch submission: POST valid batch, verify rows in database, verify response shape
- Auth enforcement: Verify 401 for unauthenticated, 403 for Read_Only users
- Transaction atomicity: Verify rollback on database error
- Frontend → Backend: Mock API, verify correct request payload from toolbar submit