19 KiB
Design Document: FP Submission Editing
Overview
This feature extends the existing FP workflow submission system to support viewing, editing, and resubmitting False Positive submissions. It adds lifecycle status tracking, an edit modal triggered from clickable workflow badges in the Reporting Table and from a submissions list in the Queue Panel, backend endpoints that proxy update/map/attach operations to the Ivanti API, and a submission history audit trail.
The design builds on the existing ivantiFpWorkflow.js route, FpWorkflowModal component, and ivanti_fp_submissions table. It follows the same conventions: factory-pattern Express routes, inline React components with the dark tactical theme, Multer for file uploads, and the ivantiFormPost() / ivantiPost() helpers for Ivanti API calls.
Key Ivanti API endpoints used for editing:
POST /workflowBatch/falsePositive/update— update workflow metadataPOST /workflowBatch/falsePositive/{uuid}/map— add findings to existing workflowPOST /workflowBatch/falsePositive/{uuid}/attach— upload additional attachments
Architecture
sequenceDiagram
participant U as User
participant FE as React Frontend
participant BE as Express Backend
participant IV as Ivanti API
participant DB as SQLite
Note over U,FE: Entry Point A: Clickable Workflow Badge
U->>FE: Click Reworked/Rejected/Expired badge in Reporting Table
FE->>FE: Look up FP_Submission by workflow batch ID
FE->>FE: Open FpEditModal pre-populated with submission data
Note over U,FE: Entry Point B: Queue Panel Submissions List
U->>FE: Click submission in Queue Panel submissions list
FE->>FE: Open FpEditModal pre-populated with submission data
Note over U,DB: Load Submission Data
FE->>BE: GET /api/ivanti/fp-submissions
BE->>DB: SELECT from ivanti_fp_submissions
DB-->>BE: Submission records
BE-->>FE: JSON array of submissions
Note over U,IV: Edit Form Fields
U->>FE: Modify name/reason/description/expiration, click Save
FE->>BE: PUT /api/ivanti/fp-submissions/:id
BE->>BE: Validate input
BE->>IV: POST /workflowBatch/falsePositive/update
IV-->>BE: 200 OK
BE->>DB: UPDATE ivanti_fp_submissions
BE->>DB: INSERT ivanti_fp_submission_history
BE->>DB: INSERT audit_log
BE-->>FE: 200 + updated record
Note over U,IV: Add Findings
U->>FE: Select additional FP queue items, click Add
FE->>BE: POST /api/ivanti/fp-submissions/:id/findings
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/map
IV-->>BE: 200 OK
BE->>DB: UPDATE finding_ids_json
BE->>DB: UPDATE queue items → complete
BE->>DB: INSERT history + audit
BE-->>FE: 200 + updated record
Note over U,IV: Add Attachments
U->>FE: Upload files, click Attach
FE->>BE: POST /api/ivanti/fp-submissions/:id/attachments (multipart)
loop Each file
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/attach
IV-->>BE: 200 OK
end
BE->>DB: UPDATE attachment_count, attachment_results_json
BE->>DB: INSERT history + audit
BE-->>FE: 200 + attachment results
Note over U,DB: Status Transition
U->>FE: Change lifecycle status
FE->>BE: PATCH /api/ivanti/fp-submissions/:id/status
BE->>DB: UPDATE lifecycle_status, INSERT history + audit
BE-->>FE: 200 OK
Components and Interfaces
Backend
Extended Route Module: backend/routes/ivantiFpWorkflow.js
Extends the existing createIvantiFpWorkflowRouter(db, requireAuth) with five new endpoints. All endpoints use requireAuth(db) and requireGroup('Admin', 'Standard_User'), and verify the authenticated user owns the submission (returning 403 otherwise).
Endpoint: GET /api/ivanti/fp-submissions
Returns the authenticated user's FP submission records.
- Auth:
requireAuth(db), any authenticated user (viewers get read-only list) - Response:
[
{
"id": 1,
"user_id": 5,
"username": "jdoe",
"ivanti_workflow_batch_id": 33418832,
"ivanti_workflow_batch_uuid": "abc-123-def",
"workflow_name": "FP - CVE-2024-1234",
"reason": "Scanner false positive",
"description": "Confirmed by manual review",
"expiration_date": "2026-12-31",
"scope_override": "Authorized",
"finding_ids_json": "[\"2283734550\",\"2283734551\"]",
"queue_item_ids_json": "[1,2]",
"attachment_count": 2,
"attachment_results_json": "[{\"filename\":\"evidence.pdf\",\"success\":true}]",
"status": "success",
"lifecycle_status": "rework",
"error_message": null,
"created_at": "2026-04-08T18:16:08",
"updated_at": "2026-04-10T12:00:00"
}
]
Endpoint: PUT /api/ivanti/fp-submissions/:id
Updates form fields and proxies to Ivanti update endpoint.
- Auth:
requireAuth(db),requireGroup('Admin', 'Standard_User') - Ownership: verified via
user_idmatch - Lifecycle guard: rejects if
lifecycle_status === 'approved' - Request body:
{
"name": "Updated FP - CVE-2024-1234",
"reason": "Updated reason",
"description": "Updated description",
"expirationDate": "2027-06-01",
"scopeOverride": "Authorized"
}
- Validation: same rules as creation form (
validateFpWorkflowForm) - Ivanti call:
POST /client/{clientId}/workflowBatch/falsePositive/updatewith JSON body containingworkflowBatchIdand updated fields - On success: updates local record, inserts history row, logs audit, sets
lifecycle_statustoresubmittedif previous status wasrejectedorrework - Response:
{ success: true, submission: { ...updatedRecord } }
Endpoint: POST /api/ivanti/fp-submissions/:id/findings
Maps additional findings to the existing workflow batch.
- Auth:
requireAuth(db),requireGroup('Admin', 'Standard_User') - Ownership: verified
- Lifecycle guard: rejects if
lifecycle_status === 'approved' - Request body:
{
"findingIds": ["2283734552", "2283734553"],
"queueItemIds": [3, 4]
}
- Validates queue items belong to user, are FP type, and pending
- Ivanti call:
POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/mapwithsubjectFilterRequestcontaining the new finding IDs - On success: appends new IDs to
finding_ids_json, marks queue items complete, inserts history + audit - Response:
{ success: true, addedFindings: [...], queueItemsUpdated: 2 }
Endpoint: POST /api/ivanti/fp-submissions/:id/attachments
Uploads additional files to the existing workflow batch.
- Auth:
requireAuth(db),requireGroup('Admin', 'Standard_User') - Content-Type:
multipart/form-data(Multer) - Ownership: verified
- Lifecycle guard: rejects if
lifecycle_status === 'approved' - File constraints: same as creation (10 MB, allowed extensions)
- Ivanti call: for each file,
POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attach - On success: updates
attachment_countandattachment_results_json, inserts history + audit - Response:
{
"success": true,
"attachmentResults": [
{ "filename": "new-evidence.pdf", "success": true },
{ "filename": "screenshot.png", "success": false, "error": "Upload failed" }
],
"status": "success"
}
Endpoint: PATCH /api/ivanti/fp-submissions/:id/status
Updates the lifecycle status of a submission.
- Auth:
requireAuth(db),requireGroup('Admin', 'Standard_User') - Ownership: verified
- Request body:
{
"lifecycle_status": "rejected"
}
- Validates status is one of:
submitted,approved,rejected,rework,resubmitted - Validates transition is allowed (cannot transition FROM
approved) - On success: updates
lifecycle_statusandupdated_at, inserts history row with previous and new status, logs audit - Response:
{ success: true, previousStatus: "submitted", newStatus: "rejected" }
Pure Helper Functions (exported for testing)
The following pure functions are extracted for testability:
validateFpWorkflowForm(body)— already exists, reused for edit validationisAllowedFileExtension(filename)— already exists, reusedbuildSubjectFilterRequest(findingIds)— already exists, reused for map endpointvalidateLifecycleTransition(currentStatus, newStatus)— new, returns{ valid: boolean, error?: string }mergeFindings(existingJson, newIds)— new, merges finding ID arrays, deduplicates, returns JSON stringbuildSubmissionHistoryEntry(changeType, details, userId, username)— new, constructs a history record object
Ivanti API Calls
Uses existing helpers from backend/helpers/ivantiApi.js:
- Update workflow:
ivantiPost()toPOST /client/{clientId}/workflowBatch/falsePositive/updatewith JSON body - Map findings:
ivantiFormPost()toPOST /client/{clientId}/workflowBatch/falsePositive/{uuid}/mapwithsubjectFilterRequest - Attach file:
ivantiMultipartPost()toPOST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attachwith file buffer
Frontend
New Component: FpEditModal
Defined inline in frontend/src/components/pages/ReportingPage.js, following the existing FpWorkflowModal pattern.
Props:
open(boolean) — controls visibilityonClose(function) — close handlersubmission(object) — the FP_Submission record to edit (null when closed)queueItems(array) — user's current queue items (for adding findings)onSuccess(function) — callback after successful edit, triggers data refresh
State:
name,reason,description,expirationDate,scopeOverride— editable form fields, initialized fromsubmissionfiles— array of new File objects for uploadadditionalFindingIds— selected queue items to add as findingssaving— boolean, disables form during saveerrors— validation error mapresult— operation result (success/failure)activeTab— current tab: 'details' | 'findings' | 'attachments' | 'history'
UI Layout:
- Modal overlay with dark backdrop (matching
FpWorkflowModal) - Header: "Edit FP Workflow — {workflow_name}" with lifecycle status badge and close button
- Tab bar: Details | Findings | Attachments | History
- Details tab: editable form fields (name, reason, description, expiration, scope override) with Save button
- Findings tab: current finding IDs list (read-only) + mechanism to select and add FP queue items
- Attachments tab: existing attachments list + file upload area for new attachments
- History tab: chronological list of changes from
ivanti_fp_submission_history - Footer: contextual action buttons per tab
- Approved submissions: all fields read-only with "This submission is finalized" message
Workflow Badge Modifications (Reporting Table)
The workflow column renderer (lines 1044–1070 of ReportingPage.js) is modified:
- For badges with state
reworked,rejected, orexpired:- Add
cursor: 'pointer'andonClickhandler - Append a small pencil icon (lucide
Edit3, 10px) after the state text - On hover: increase border opacity and brighten background
- On click: look up matching FP_Submission by
wf.id(workflow batch ID), openFpEditModal
- Add
- For badges with state
requestedorapproved:- No changes — remain non-interactive (no cursor, no icon, no click handler)
QueuePanel Modifications
- Add a "Submissions" section below the existing queue items list
- Fetches submissions via
GET /api/ivanti/fp-submissionson panel open - Each submission row shows: workflow name, batch ID, lifecycle status badge, finding count, created date
- Lifecycle status badges use color coding: submitted (sky blue), approved (emerald), rejected (red), rework (amber), resubmitted (sky blue)
- Clicking a submission row opens
FpEditModalwith that submission's data - Viewers see the list but cannot click to edit
Lifecycle Status Badge Component
Inline helper function lifecycleStatusBadge(status) returning style object:
| Status | Border | Background | Text |
|---|---|---|---|
| submitted | rgba(14,165,233,0.4) |
rgba(14,165,233,0.12) |
#0EA5E9 |
| approved | rgba(16,185,129,0.4) |
rgba(16,185,129,0.12) |
#10B981 |
| rejected | rgba(239,68,68,0.4) |
rgba(239,68,68,0.12) |
#EF4444 |
| rework | rgba(245,158,11,0.4) |
rgba(245,158,11,0.12) |
#F59E0B |
| resubmitted | rgba(14,165,233,0.4) |
rgba(14,165,233,0.12) |
#0EA5E9 |
Data Models
Schema Changes to ivanti_fp_submissions
Three new columns added to the existing table:
ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted'
CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'));
ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT;
ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
New Table: ivanti_fp_submission_history
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER NOT NULL,
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 DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
change_details_json examples:
fields_updated:{"changed": {"name": {"from": "old", "to": "new"}, "reason": {"from": "old", "to": "new"}}}findings_added:{"addedFindingIds": ["123", "456"], "queueItemIds": [3, 4]}attachments_added:{"files": [{"filename": "evidence.pdf", "success": true}]}status_changed:{"from": "submitted", "to": "rejected"}created:{"workflowBatchId": 33418832, "findingCount": 3, "attachmentCount": 1}
Migration Script: backend/migrations/add_fp_submission_editing.js
Applies all schema changes idempotently using ALTER TABLE ... ADD COLUMN wrapped in try/catch (SQLite throws if column already exists) and CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS.
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.
Note: Properties for validateFpWorkflowForm and isAllowedFileExtension are already covered by the existing ivanti-fp-workflow-submission spec and are reused without modification. The properties below cover new pure functions introduced by this feature.
Property 1: Finding Merge Preserves All IDs and Deduplicates
For any existing finding IDs JSON string (valid JSON array of strings) and any array of new finding ID strings, mergeFindings(existingJson, newIds) should produce a JSON string that, when parsed, contains every ID from the original array and every ID from the new array, contains no duplicate entries, and has a length less than or equal to the sum of the original and new array lengths.
Validates: Requirements 3.3
Property 2: Lifecycle Transition Validation
For any pair of lifecycle status values (currentStatus, newStatus) drawn from the set {submitted, approved, rejected, rework, resubmitted}, validateLifecycleTransition(currentStatus, newStatus) should return { valid: false } whenever currentStatus is "approved" (no transitions allowed from finalized state), and should return { valid: true } for all other currentStatus values when newStatus is a valid lifecycle status. Additionally, when currentStatus is "rejected" or "rework" and newStatus is "resubmitted", the transition should always be valid.
Validates: Requirements 5.4, 5.5
Error Handling
Ivanti API Errors
| HTTP Status | Endpoint | User-Facing Message | System Behavior |
|---|---|---|---|
| 401 | All | "Ivanti API key is invalid or missing. Contact your administrator." | Log error, preserve form state |
| 419 | All | "API key lacks permissions for this operation." | Log error, preserve form state |
| 429 | All | "Ivanti API rate limit reached. Please try again in a few minutes." | Log error, preserve form state |
| 5xx | All | "Ivanti API is temporarily unavailable. Please try again later." | Log error, preserve form state |
| Other | All | "Operation failed: {status} — {message}" | Log error with full response, preserve form state |
Partial Failure (Attachment Upload)
When some attachment uploads succeed and others fail:
- Response includes per-file success/failure details
- Successfully uploaded files are recorded in
attachment_results_json - Failed files are reported to the user with retry option
- The submission record is updated with the successful uploads only
Lifecycle Guard Errors
- Attempting to edit an "approved" submission returns 400:
"This submission is finalized and cannot be edited." - Attempting an invalid status transition returns 400:
"Cannot transition from {current} to {new}."
Ownership Errors
- All edit endpoints verify
user_idmatches the authenticated user - Mismatch returns 403:
"You can only edit your own submissions."
Local Database Errors
- If history INSERT fails: log error, still return success (the Ivanti operation succeeded)
- If audit log INSERT fails: fire-and-forget (existing
logAudit()pattern) - If submission record UPDATE fails: return 500 with error message
Testing Strategy
Property-Based Testing
Use fast-check as the property-based testing library. Each correctness property maps to a single property-based test with a minimum of 100 iterations.
Property tests focus on the new pure functions:
mergeFindings(existingJson, newIds)— Property 1validateLifecycleTransition(currentStatus, newStatus)— Property 2
Tag format: Feature: fp-submission-editing, Property {number}: {title}
Test file: backend/__tests__/fpSubmissionEditing.property.test.js
Unit Testing
Unit tests cover specific examples, edge cases, and integration points:
- Validation reuse: verify
validateFpWorkflowFormis called correctly in the PUT endpoint - Lifecycle badge styles: verify each of the 5 statuses maps to the correct color scheme
- Clickable badge logic: verify reworked/rejected/expired states produce clickable badges, requested/approved do not
- Ownership verification: verify 403 when non-owner attempts edit
- Role guard: verify non-Admin/Standard_User users are rejected
- Approved guard: verify 400 when editing an approved submission
- Error mapping: verify each Ivanti HTTP status maps to the correct error message
- History recording: verify correct
change_typeandchange_details_jsonfor each operation type - Migration idempotency: verify migration can run multiple times without error
Test file: backend/__tests__/fpSubmissionEditing.test.js
Integration Testing
Integration tests verify the full request/response cycle with mocked Ivanti API:
- GET submissions returns correct records for authenticated user
- PUT update proxies to Ivanti and updates local record
- POST findings maps to Ivanti and merges finding IDs
- POST attachments uploads to Ivanti and updates attachment records
- PATCH status updates lifecycle and creates history entry
- Queue items marked complete after successful finding addition
Test file: backend/__tests__/fpSubmissionEditing.integration.test.js