371 lines
16 KiB
Markdown
371 lines
16 KiB
Markdown
|
|
# 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, 1–200 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 1–200 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 1–7 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'`
|