Files
cve-dashboard/.kiro/specs/flexible-jira-ticket-creation/design.md
Jordan Ramos a61d254ff9 Sync .kiro/ from master — v2.2.0 release batch
New specs: archer-template-library, ccp-metrics-view-restructure,
compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date,
compliance-remediation-display-fix, flexible-jira-ticket-creation,
forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix,
multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown

New steering: archer-template-gen.md

Updated: migration-registration-check hook, remediation-plan-history spec,
gitlab-workflow, tech, versioning steering files
2026-06-04 11:27:31 -06:00

371 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Document: Flexible Jira Ticket Creation
## Overview
This feature decouples Jira ticket creation from the CVE-only workflow by making CVE ID and Vendor optional, adding a `source_context` field to track ticket origin, and exposing "Create Jira Ticket" actions from the Ivanti queue and Archer detail views. The changes span a database migration, backend validation updates, and frontend modal/list enhancements.
The design preserves backward compatibility — existing tickets retain their CVE ID and Vendor values, and the new `source_context` column defaults to `manual` for legacy rows.
## Architecture
```mermaid
flowchart TD
subgraph Frontend
JiraPage[JiraPage.js<br/>Creation Modal + Ticket List]
IvantiQueue[Ivanti Queue Page<br/>Create Jira Ticket action]
ArcherView[App.js Archer Detail<br/>Create Jira Ticket action]
end
subgraph Backend
Route[POST /api/jira-tickets/create-in-jira]
Validation[Input Validation Layer]
JiraAPI[jiraApi.js → Jira REST API]
DB[(PostgreSQL<br/>jira_tickets table)]
end
IvantiQueue -->|opens modal with pre-populated fields| JiraPage
ArcherView -->|opens modal with pre-populated fields| JiraPage
JiraPage -->|POST payload| Route
Route --> Validation
Validation -->|valid| JiraAPI
JiraAPI -->|issue created| DB
Validation -->|invalid| Route
Route -->|400/201| JiraPage
```
The modal component is reused across all three entry points (Jira page, Ivanti queue, Archer detail). Each entry point passes pre-populated field values and a locked `source_context` value to the modal.
## Components and Interfaces
### Backend: `POST /api/jira-tickets/create-in-jira` (Updated)
**Request Body:**
| Field | Type | Required | Validation |
|---|---|---|---|
| `summary` | string | Yes | Non-empty after trim, max 255 chars |
| `cve_id` | string \| null | No | If present and non-empty, must match `CVE-YYYY-NNNN+`. Empty string treated as null. |
| `vendor` | string \| null | No | If present and non-empty, 1200 chars after trim. Empty string treated as null. |
| `source_context` | string \| null | No | Must be one of: `cve`, `archer`, `ivanti_queue`, `email`, `manual`. Defaults to `manual` if absent. |
| `description` | string \| null | No | Free text, passed to Jira issue body |
| `project_key` | string \| null | No | Falls back to `JIRA_PROJECT_KEY` env var |
| `issue_type` | string \| null | No | Falls back to `JIRA_ISSUE_TYPE` env var |
**Response (201):**
```json
{
"id": 42,
"ticket_key": "VULN-789",
"jira_url": "https://jira.example.com/browse/VULN-789",
"source_context": "ivanti_queue",
"message": "Jira issue created and linked successfully"
}
```
**Validation Logic (pseudocode):**
```
if cve_id is empty string → treat as null
if cve_id is non-null and non-empty:
if not matching /^CVE-\d{4}-\d{4,}$/ → 400
if vendor is empty string or whitespace-only → treat as null
if vendor is non-null and non-empty:
trim whitespace
if length > 200 → 400
if source_context is present:
if not in ALLOWED_SET → 400
else:
source_context = 'manual'
if summary is empty or > 255 chars → 400
```
### Backend: `PUT /api/jira-tickets/:id` (Updated)
The update endpoint rejects any attempt to change `source_context`:
```
if request body contains source_context field → 400 "source_context is immutable after creation"
```
### Backend: `GET /api/jira-tickets` (Updated)
Response now includes `source_context` on each ticket object. No other changes to the GET endpoint.
### Frontend: Creation Modal (Updated)
The existing `showCreateJira` modal in `JiraPage.js` is updated:
- CVE ID label: `"CVE ID (optional)"` with placeholder `"e.g. CVE-2024-12345"`
- Vendor label: `"Vendor (optional)"` with placeholder `"e.g. Microsoft"`
- New Source Context dropdown: options map display labels to API values
- Summary remains required with inline validation error on empty submit
- Form state extended with `source_context` field
**Source Context Options Mapping:**
| Display Label | API Value |
|---|---|
| CVE | `cve` |
| Archer Request | `archer` |
| Ivanti Queue | `ivanti_queue` |
| Email | `email` |
| Manual | `manual` |
When opened from Ivanti queue or Archer context, the source_context dropdown is pre-selected and read-only.
### Frontend: Ivanti Queue Integration
A "Create Jira Ticket" button is added to each queue item row (or action menu). When activated:
1. Opens the Creation Modal
2. Pre-populates `summary` with `finding_title` (truncated to 255 chars)
3. Pre-populates `cve_id` with first element of `cves_json` array (if non-empty)
4. Pre-populates `vendor` with queue item's `vendor` value (if present)
5. Sets `source_context` to `ivanti_queue` (locked)
### Frontend: Archer Detail Integration
A "Create Jira Ticket" button is added to the Archer ticket detail view (visible to editor/admin roles). When activated:
1. Opens the Creation Modal
2. Pre-populates `summary` with `exc_number` (e.g., "EXC-1234")
3. Pre-populates `cve_id` with Archer ticket's `cve_id` (if present)
4. Pre-populates `vendor` with Archer ticket's `vendor` (if present)
5. Sets `source_context` to `archer` (locked)
### Frontend: Ticket List Updates
The ticket list table gains:
- A new "Source" column between Vendor and Summary, displaying a color-coded badge
- A source_context dropdown filter (matching the existing status filter pattern)
- Search includes `source_context` in the filterable fields
**Badge Color Mapping:**
| Source Context | Badge Color |
|---|---|
| `cve` | `#0EA5E9` (blue) |
| `archer` | `#8B5CF6` (purple) |
| `ivanti_queue` | `#F59E0B` (amber) |
| `email` | `#10B981` (green) |
| `manual` | `#94A3B8` (gray) |
Null/empty source_context displays as "CVE" badge (blue) for backward compatibility with legacy tickets.
## Data Models
### `jira_tickets` Table (Updated Schema)
```sql
CREATE TABLE IF NOT EXISTS jira_tickets (
id SERIAL PRIMARY KEY,
cve_id TEXT, -- Changed: NULL allowed
vendor TEXT, -- Changed: NULL allowed
ticket_key TEXT NOT NULL,
url TEXT,
summary TEXT,
status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')),
source_context TEXT DEFAULT 'manual', -- New column
jira_id TEXT,
jira_status TEXT,
last_synced_at TIMESTAMPTZ,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
**Constraints:**
- `source_context` has a CHECK constraint limiting values to `('cve', 'archer', 'ivanti_queue', 'email', 'manual')`
- `cve_id` allows NULL (NOT NULL constraint dropped)
- `vendor` allows NULL (NOT NULL constraint dropped)
### Migration: `add_flexible_jira_ticket_creation.js`
```javascript
const pool = require('../db');
async function run() {
console.log('Starting flexible Jira ticket creation migration...');
// Verify table exists
const { rows } = await pool.query(`
SELECT 1 FROM information_schema.tables
WHERE table_name = 'jira_tickets'
`);
if (rows.length === 0) {
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
process.exit(1);
}
// Drop NOT NULL on cve_id
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
// Drop NOT NULL on vendor
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
// Add source_context column with default
await pool.query(`
ALTER TABLE jira_tickets
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
`);
console.log('✓ source_context column added (or already exists)');
// Add CHECK constraint (idempotent via IF NOT EXISTS pattern)
await pool.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
) THEN
ALTER TABLE jira_tickets
ADD CONSTRAINT jira_tickets_source_context_check
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
END IF;
END $$;
`);
console.log('✓ source_context CHECK constraint added (or already exists)');
// Backfill existing rows (DEFAULT handles this, but explicit for clarity)
await pool.query(`
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
`);
console.log('✓ Existing rows backfilled with source_context = manual');
// Index for filtering
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
ON jira_tickets(source_context)
`);
console.log('✓ source_context index created (or already exists)');
console.log('Migration complete.');
process.exit(0);
}
run().catch(err => {
console.error('Migration failed:', err.message);
process.exit(1);
});
```
**Idempotency:** `ALTER COLUMN DROP NOT NULL` is safe to run multiple times (no-op if already nullable). `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are inherently idempotent. The CHECK constraint uses a `DO $$ ... IF NOT EXISTS` guard.
## 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: CVE ID validation and storage
*For any* create-ticket request payload, if `cve_id` is absent, null, or an empty string, the service SHALL accept the request and store NULL for `cve_id`; if `cve_id` is a non-empty string matching `CVE-YYYY-NNNN+`, the service SHALL store that exact value.
**Validates: Requirements 1.1, 1.2, 1.4**
### Property 2: Invalid CVE ID rejection
*For any* non-empty string that does not match the pattern `CVE-YYYY-NNNN+` (four-digit year, four or more digit sequence), the service SHALL reject the create-ticket request with HTTP 400.
**Validates: Requirements 1.3**
### Property 3: Vendor validation and storage
*For any* create-ticket request payload, if `vendor` is absent, null, empty, or whitespace-only, the service SHALL store NULL; if `vendor` is a non-empty string of 1200 characters after trimming, the service SHALL store the trimmed value.
**Validates: Requirements 2.1, 2.2**
### Property 4: Over-length vendor rejection
*For any* string that, after trimming whitespace, exceeds 200 characters, the service SHALL reject the create-ticket request with a validation error.
**Validates: Requirements 2.3**
### Property 5: Invalid source_context rejection
*For any* string not in the set `{cve, archer, ivanti_queue, email, manual}`, the service SHALL reject the create-ticket request with HTTP 400 when that string is provided as `source_context`.
**Validates: Requirements 3.3**
### Property 6: source_context round-trip persistence
*For any* valid `source_context` value in `{cve, archer, ivanti_queue, email, manual}`, creating a ticket with that value and then fetching the ticket via GET SHALL return the same `source_context` value.
**Validates: Requirements 3.4**
### Property 7: source_context immutability
*For any* existing ticket and any `source_context` value (valid or invalid), an update request that includes a `source_context` field SHALL be rejected with HTTP 400.
**Validates: Requirements 3.6**
### Property 8: Summary pre-population truncation from Ivanti queue
*For any* Ivanti queue item with a `finding_title` of arbitrary length, the pre-populated summary in the Creation Modal SHALL be at most 255 characters and SHALL equal the first 255 characters of `finding_title`.
**Validates: Requirements 5.2**
### Property 9: Search includes source_context
*For any* ticket whose `source_context` value contains the search term as a substring, that ticket SHALL appear in the filtered results when the user searches by that term.
**Validates: Requirements 8.4**
## Error Handling
| Scenario | HTTP Status | Error Message | Client Behavior |
|---|---|---|---|
| Invalid CVE ID format | 400 | "CVE ID format is invalid. Expected CVE-YYYY-NNNN+." | Display inline error, preserve form |
| Vendor exceeds 200 chars | 400 | "Vendor exceeds maximum length of 200 characters." | Display inline error, preserve form |
| Invalid source_context | 400 | "source_context must be one of: cve, archer, ivanti_queue, email, manual." | Display inline error, preserve form |
| source_context update attempt | 400 | "source_context is immutable after creation." | Display inline error |
| Empty summary | 400 | "Summary is required (max 255 chars)." | Prevent submission, show inline error |
| Jira API unavailable | 502 | "Failed to create Jira issue." | Display error banner, preserve form values for retry |
| Jira rate limit exceeded | 429 | "Jira rate limit exceeded. Try again later." | Display error banner, preserve form values |
| Jira API not configured | 503 | "Jira API is not configured." | Display error banner |
| DB insert fails after Jira creation | 207 | Warning with Jira key/URL | Display warning with Jira link |
| jira_tickets table missing (migration) | Exit code 1 | Console error | Migration aborts cleanly |
**Error preservation:** When the Jira API call fails or returns an error, the frontend modal retains all user-entered form data so the user can retry without re-entering information. This applies to all three entry points (Jira page, Ivanti queue, Archer detail).
## Testing Strategy
### Unit Tests (Example-Based)
- Modal renders with "(optional)" labels and correct placeholders
- Modal allows submission with empty CVE ID and Vendor
- Source Context dropdown contains all five options with no default selection
- Source Context dropdown sends correct API value for each display label
- Summary validation prevents empty submission with inline error
- Ivanti queue "Create Jira Ticket" button is visible on queue items
- Archer detail "Create Jira Ticket" button visible for editor/admin, hidden for viewer
- Archer pre-populates summary with `exc_number`
- Ticket list displays source_context badge between Vendor and Summary columns
- Null source_context displays "CVE" badge text
- Source context filter dropdown includes "All" and all distinct values
- API error preserves form field values in modal
### Property-Based Tests
Property-based testing is appropriate for this feature because the backend validation logic operates on a wide input space (arbitrary strings for CVE IDs, vendors, and source_context values) where universal properties must hold.
**Library:** fast-check (already available in the project's test infrastructure via Jest)
**Configuration:**
- Minimum 100 iterations per property test
- Each test tagged with: `Feature: flexible-jira-ticket-creation, Property {N}: {title}`
Properties 17 test backend validation logic (pure input → output behavior, mockable Jira API).
Property 8 tests frontend pre-population logic (pure string truncation).
Property 9 tests frontend filtering logic (pure array filter).
### Integration Tests
- Migration runs successfully on a database with existing `jira_tickets` rows, preserving all data
- Migration is idempotent (running twice produces same result)
- Migration fails gracefully when `jira_tickets` table doesn't exist
- End-to-end: create ticket from Ivanti queue context → verify stored with `source_context = 'ivanti_queue'`
- End-to-end: create ticket from Archer context → verify stored with `source_context = 'archer'`