Add flexible Jira ticket creation — CVE/Vendor optional, source context tracking
Make CVE ID and Vendor optional when creating Jira tickets. Add source_context field to track ticket origin (cve, archer, ivanti_queue, email, manual). - Migration: drop NOT NULL on cve_id/vendor, add source_context column with CHECK - Backend: update create/update/get endpoints for optional fields and source_context - Frontend: update creation modal with optional labels and source context dropdown - Add Create Jira Ticket action from Ivanti queue (pre-populates from finding) - Add Create Jira Ticket action from Archer detail view (pre-populates from ticket) - Add source context badge column, filter dropdown, and search to ticket list
This commit is contained in:
@@ -35,6 +35,16 @@ function createJiraTicketsRouter() {
|
||||
// Jira API integration endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/connection-test
|
||||
*
|
||||
* Tests connectivity to the configured Jira instance.
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { connected: true, user: { name, displayName, ... } }
|
||||
* @returns {object} 502 - { connected: false, error: string } on connection failure
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
@@ -60,10 +70,33 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/rate-limit
|
||||
*
|
||||
* Returns the current Jira API rate limit status (burst and daily counters).
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
||||
*/
|
||||
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/lookup/:issueKey
|
||||
*
|
||||
* Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns
|
||||
* a summary of its fields.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
||||
* @requires Authenticated user
|
||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @returns {object} 400 - { error: string } for invalid issue key format
|
||||
* @returns {object} 404 - { error: string } when issue not found in Jira
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -102,19 +135,63 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/create-in-jira
|
||||
*
|
||||
* Creates a new issue in Jira and saves a local tracking record.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
|
||||
* @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace
|
||||
* @body {string} summary - Required issue summary (max 255 chars)
|
||||
* @body {string} [description] - Optional 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)
|
||||
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual')
|
||||
* @returns {object} 201 - { id, ticket_key, jira_url, source_context, message }
|
||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
// --- CVE ID validation: optional, but must match format if non-empty ---
|
||||
let normalizedCveId = null;
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
|
||||
// --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty ---
|
||||
let normalizedVendor = null;
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
const trimmedVendor = vendor.trim();
|
||||
if (trimmedVendor.length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
normalizedVendor = trimmedVendor;
|
||||
}
|
||||
|
||||
// --- source_context validation: must be in allowed set if provided, default to 'manual' ---
|
||||
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
|
||||
let normalizedSourceContext = 'manual';
|
||||
if (source_context !== undefined && source_context !== null) {
|
||||
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
|
||||
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
|
||||
}
|
||||
normalizedSourceContext = source_context;
|
||||
}
|
||||
|
||||
// --- Summary validation: required, non-empty, max 255 chars ---
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
@@ -153,10 +230,10 @@ function createJiraTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10)
|
||||
RETURNING id`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
|
||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
@@ -165,7 +242,7 @@ function createJiraTicketsRouter() {
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -173,6 +250,7 @@ function createJiraTicketsRouter() {
|
||||
id: rows[0].id,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
source_context: normalizedSourceContext,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
} catch (dbErr) {
|
||||
@@ -189,6 +267,18 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/sync-all
|
||||
*
|
||||
* Syncs all local Jira ticket records with their current Jira status using
|
||||
* bulk JQL search. Updates summary, status, and last_synced_at for each ticket.
|
||||
* Stops early if rate limits are approaching.
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -284,6 +374,22 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/:id/sync
|
||||
*
|
||||
* Syncs a single local Jira ticket record with its current Jira status.
|
||||
* Fetches the issue by ticket_key and updates summary, status, and last_synced_at.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @returns {object} 400 - { error: string } when ticket has no Jira key
|
||||
* @returns {object} 404 - { error: string } when local ticket not found
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -347,8 +453,22 @@ function createJiraTicketsRouter() {
|
||||
// Local CRUD endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets
|
||||
*
|
||||
* Lists all Jira tickets with optional filtering by query parameters.
|
||||
* Results are ordered by created_at descending.
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by exact CVE ID
|
||||
* @query {string} [vendor] - Filter by exact vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
|
||||
* @requires Authenticated user
|
||||
* @returns {array} 200 - Array of jira_tickets rows
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
const { cve_id, vendor, status, source_context } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
@@ -366,6 +486,10 @@ function createJiraTicketsRouter() {
|
||||
query += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
if (source_context) {
|
||||
query += ` AND source_context = $${paramIndex++}`;
|
||||
params.push(source_context);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
@@ -378,6 +502,23 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets
|
||||
*
|
||||
* Creates a local Jira ticket record (without creating an issue in Jira).
|
||||
* Used for manually tracking tickets that already exist in Jira.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+)
|
||||
* @body {string} vendor - Required vendor name (max 200 chars)
|
||||
* @body {string} ticket_key - Required Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Optional Jira ticket URL (max 500 chars)
|
||||
* @body {string} [summary] - Optional summary (max 500 chars)
|
||||
* @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
@@ -430,10 +571,32 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jira-tickets/:id
|
||||
*
|
||||
* Updates an existing local Jira ticket record. Only provided fields are updated.
|
||||
* The source_context field is immutable after creation — including it returns 400.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
|
||||
* @body {string} [summary] - Summary (max 500 chars, null to clear)
|
||||
* @body {string} [status] - Status: Open, In Progress, or Closed
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// source_context is immutable after creation (Requirement 3.6)
|
||||
if ('source_context' in req.body) {
|
||||
return res.status(400).json({ error: 'source_context is immutable after creation' });
|
||||
}
|
||||
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
@@ -492,6 +655,20 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jira-tickets/:id
|
||||
*
|
||||
* Deletes a local Jira ticket record. Admin can delete any ticket.
|
||||
* Standard_User can only delete tickets they created, and only if the ticket
|
||||
* is not linked to an active compliance item.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @returns {object} 200 - { message }
|
||||
* @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user