Files
cve-dashboard/backend/helpers/jiraApi.js
Jordan Ramos 2656df94d3 feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema)
- Create shared KNOWN_TEAMS constant and validateTeams helper
- Expose user teams in auth middleware, login, and /me responses
- Add bu_teams CRUD to user management routes with audit logging
- Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var
- Add query-time team filtering to GET /findings and /findings/counts
- Update AuthContext with teams helpers and admin scope toggle
- Create AdminScopeToggle component (My Teams / All BUs)
- Scope ReportingPage findings fetch by user teams
- Scope CompliancePage team selector by user teams
- Scope ExportsPage findings exports by user teams
- Add BU teams multi-select to UserManagement create/edit forms
- Display team badges in user list table
2026-05-05 11:04:53 -06:00

454 lines
16 KiB
JavaScript

// Shared Jira Data Center REST API helpers
// Centralizes HTTP calls for Jira issue operations.
// Follows the same promise-based pattern as atlasApi.js and ivantiApi.js.
//
// =========================================================================
// Charter Jira REST API Compliance
// =========================================================================
// Authentication:
// - Service accounts use Basic Auth (required for shared integrations).
// - PATs require ATLSUP approval and naming convention:
// Function - Team - Approved ATLSUP ticket
// - SSO must NOT be used for REST API integrations.
//
// Rate limiting (Charter-posted):
// - 1 440 requests/day max
// - Burst cap of 60 requests/minute (accumulates 1 req/idle minute)
// - 429 response when limits are hit server-side
//
// Automation delays (Charter requirement):
// - 1 second delay between GET requests
// - 2 second delay between PUT, POST, or DELETE requests
//
// Forbidden patterns:
// - /rest/api/2/field — must specify fields explicitly in every call
// - /rest/api/2/issue/bulk — bulk updates are not allowed
// - Single-issue GET loops — use bulk JQL search instead
//
// Required patterns:
// - All GET requests MUST include a ?fields= parameter
// - JQL MUST include at least one of: project+updated, assignee+updated,
// status+updated
// - JQL should use &updated>=-Xh to only fetch changed issues
// - maxResults=1000 for search queries
// - Issues must be updated one at a time (no bulk PUT)
// =========================================================================
const https = require('https');
const http = require('http');
// ---------------------------------------------------------------------------
// Configuration — read from process.env at module load
// ---------------------------------------------------------------------------
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
const JIRA_AUTH_METHOD = (process.env.JIRA_AUTH_METHOD || 'basic').toLowerCase();
const JIRA_API_USER = process.env.JIRA_API_USER || '';
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
const JIRA_PAT = process.env.JIRA_PAT || '';
const JIRA_SKIP_TLS = process.env.JIRA_SKIP_TLS === 'true';
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY || '';
const JIRA_ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || 'Task';
const requiredVars = JIRA_AUTH_METHOD === 'pat'
? ['JIRA_BASE_URL', 'JIRA_PAT']
: ['JIRA_BASE_URL', 'JIRA_API_USER', 'JIRA_API_TOKEN'];
const missingVars = requiredVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.warn(`[jira-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Jira API calls will fail.`);
}
const isConfigured = missingVars.length === 0;
// ---------------------------------------------------------------------------
// Default fields — every GET must specify fields explicitly.
// /rest/api/2/field is forbidden; we define the field list here.
// ---------------------------------------------------------------------------
const DEFAULT_FIELDS = [
'summary', 'status', 'assignee', 'created', 'updated',
'priority', 'issuetype', 'project', 'resolution'
];
// ---------------------------------------------------------------------------
// Rate limiter — enforces Charter's posted limits
// 1 440 events/day, burst of 60 events/minute
// ---------------------------------------------------------------------------
const DAILY_LIMIT = 1440;
const BURST_LIMIT = 60;
const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
let dailyLog = [];
let minuteLog = [];
function pruneLog(log, windowMs) {
const cutoff = Date.now() - windowMs;
while (log.length > 0 && log[0] < cutoff) {
log.shift();
}
}
function checkRateLimit() {
pruneLog(dailyLog, DAY_MS);
pruneLog(minuteLog, MINUTE_MS);
if (dailyLog.length >= DAILY_LIMIT) {
return { allowed: false, reason: `Daily Jira API limit reached (${DAILY_LIMIT}/day). Resets at midnight.` };
}
if (minuteLog.length >= BURST_LIMIT) {
return { allowed: false, reason: `Burst Jira API limit reached (${BURST_LIMIT}/min). Wait and retry.` };
}
return { allowed: true };
}
function recordRequest() {
const now = Date.now();
dailyLog.push(now);
minuteLog.push(now);
}
/**
* Return current rate limit usage for diagnostics.
*/
function getRateLimitStatus() {
pruneLog(dailyLog, DAY_MS);
pruneLog(minuteLog, MINUTE_MS);
return {
daily: { used: dailyLog.length, limit: DAILY_LIMIT, remaining: DAILY_LIMIT - dailyLog.length },
burst: { used: minuteLog.length, limit: BURST_LIMIT, remaining: BURST_LIMIT - minuteLog.length }
};
}
// ---------------------------------------------------------------------------
// Inter-request delay — Charter automation requirements
// 1s between GETs, 2s between PUT/POST/DELETE
// ---------------------------------------------------------------------------
const GET_DELAY_MS = 1000;
const WRITE_DELAY_MS = 2000;
let lastRequestTime = 0;
let lastRequestMethod = '';
/**
* Wait the required delay before issuing the next request.
* GET → 1s, PUT/POST/DELETE → 2s since the previous request.
*/
function waitForDelay(method) {
const now = Date.now();
const requiredDelay = (lastRequestMethod === 'GET') ? GET_DELAY_MS
: (lastRequestMethod !== '') ? WRITE_DELAY_MS : 0;
const elapsed = now - lastRequestTime;
const remaining = requiredDelay - elapsed;
if (remaining > 0) {
return new Promise(resolve => setTimeout(resolve, remaining));
}
return Promise.resolve();
}
// ---------------------------------------------------------------------------
// Blocked endpoint guard
// ---------------------------------------------------------------------------
const BLOCKED_PATHS = [
'/rest/api/2/field', // Must specify fields in call, not query field list
'/rest/api/2/issue/bulk', // Bulk updates are not allowed
];
function isBlockedPath(urlPath) {
return BLOCKED_PATHS.some(blocked => urlPath.startsWith(blocked));
}
// ---------------------------------------------------------------------------
// Generic request — supports GET, POST, PUT, DELETE
// Enforces rate limits, inter-request delays, and blocked-path guards.
// ---------------------------------------------------------------------------
async function jiraRequest(method, urlPath, body, options) {
// Block forbidden endpoints
if (isBlockedPath(urlPath)) {
return Promise.reject(new Error(`Blocked: ${urlPath} is not allowed per Charter Jira API policy.`));
}
const limit = checkRateLimit();
if (!limit.allowed) {
return Promise.reject(new Error(limit.reason));
}
// Enforce inter-request delay
await waitForDelay(method);
const timeout = (options && options.timeout) || 15000;
const fullUrl = new URL(JIRA_BASE_URL + urlPath);
const isHttps = fullUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const headers = {
'accept': 'application/json'
};
// Auth header
if (JIRA_AUTH_METHOD === 'pat') {
headers['authorization'] = 'Bearer ' + JIRA_PAT;
} else {
const authString = Buffer.from(JIRA_API_USER + ':' + JIRA_API_TOKEN).toString('base64');
headers['authorization'] = 'Basic ' + authString;
}
let bodyStr = null;
if (body !== null && body !== undefined) {
bodyStr = JSON.stringify(body);
headers['content-type'] = 'application/json';
headers['content-length'] = Buffer.byteLength(bodyStr);
}
recordRequest();
lastRequestTime = Date.now();
lastRequestMethod = method;
return new Promise((resolve, reject) => {
const reqOptions = {
hostname: fullUrl.hostname,
port: fullUrl.port || (isHttps ? 443 : 80),
path: fullUrl.pathname + fullUrl.search,
method: method,
headers: headers,
timeout: timeout
};
if (isHttps) {
reqOptions.rejectUnauthorized = !JIRA_SKIP_TLS;
}
const req = transport.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 429) {
resolve({ status: 429, body: data, rateLimited: true });
} else {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
req.on('error', (err) => {
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
});
if (bodyStr) {
req.write(bodyStr);
}
req.end();
});
}
// ---------------------------------------------------------------------------
// Convenience wrappers
// ---------------------------------------------------------------------------
function jiraGet(urlPath, options) {
return jiraRequest('GET', urlPath, null, options);
}
function jiraPost(urlPath, body, options) {
return jiraRequest('POST', urlPath, body, options);
}
function jiraPut(urlPath, body, options) {
return jiraRequest('PUT', urlPath, body, options);
}
function jiraDelete(urlPath, options) {
return jiraRequest('DELETE', urlPath, null, options);
}
// ---------------------------------------------------------------------------
// High-level Jira operations — all comply with Charter requirements
// ---------------------------------------------------------------------------
/**
* Fetch a single issue by key using a GET with explicit ?fields= parameter.
* Charter requires all GETs to specify fields — /rest/api/2/field is forbidden.
*
* NOTE: For syncing multiple tickets, prefer searchIssuesByKeys() which uses
* a single bulk JQL search instead of one GET per issue.
*
* @param {string} issueKey - e.g. "VULN-123"
* @param {string[]} [fields] - Jira field names to return
*/
async function getIssue(issueKey, fields) {
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
if (result.ok && result.data.issues && result.data.issues.length > 0) {
return { ok: true, data: result.data.issues[0] };
}
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
return { ok: false, status: 404, body: 'Issue not found' };
}
return result;
}
/**
* Bulk-fetch issues by their keys using a single JQL search.
* This is the Charter-compliant way to sync multiple tickets — avoids
* querying one issue at a time.
*
* @param {string[]} issueKeys - Array of Jira issue keys
* @param {object} [opts] - { fields, maxResults }
*/
async function searchIssuesByKeys(issueKeys, opts) {
if (!issueKeys || issueKeys.length === 0) {
return { ok: true, data: { total: 0, issues: [] } };
}
// Build JQL: key in (KEY-1, KEY-2, ...) — Charter requires project+updated
// or similar, but key-based search is inherently scoped. We add updated
// clause for compliance.
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
const jql = `key in (${keyList}) AND updated >= -72h AND project = ${JIRA_PROJECT_KEY}`;
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
return searchIssues(jql, { fields, maxResults, startAt: 0 });
}
/**
* Search issues via JQL (POST to /rest/api/2/search).
* Charter requirements enforced:
* - fields array is always specified (never omitted)
* - maxResults capped at 1000
*
* The caller is responsible for including an &updated clause in the JQL
* for recurring/scheduled queries.
*
* @param {string} jql - JQL query string
* @param {object} [opts] - { startAt, maxResults, fields }
*/
async function searchIssues(jql, opts) {
const startAt = (opts && opts.startAt) || 0;
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
const fieldList = encodeURIComponent(fields.join(','));
const encodedJql = encodeURIComponent(jql);
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
const res = await jiraGet('/rest/api/2/search' + queryString);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Create a new Jira issue (POST, subject to 2s delay).
* @param {object} fields - Jira issue fields object
*/
async function createIssue(fields) {
const res = await jiraPost('/rest/api/2/issue', { fields });
if (res.status === 201) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Update a single Jira issue (PUT, subject to 2s delay).
* Charter forbids bulk updates — issues must be updated one at a time.
* @param {string} issueKey
* @param {object} fields - Fields to update
*/
async function updateIssue(issueKey, fields) {
const res = await jiraPut(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
{ fields }
);
// Jira returns 204 on successful update
if (res.status === 204) {
return { ok: true };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Add a comment to an existing issue (POST, subject to 2s delay).
*/
async function addComment(issueKey, commentBody) {
const res = await jiraPost(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`,
{ body: commentBody }
);
if (res.status === 201) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Transition an issue to a new status (POST, subject to 2s delay).
* @param {string} issueKey
* @param {string} transitionId
*/
async function transitionIssue(issueKey, transitionId) {
const res = await jiraPost(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`,
{ transition: { id: transitionId } }
);
if (res.status === 204) {
return { ok: true };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Get available transitions for an issue.
* Uses GET with explicit fields parameter (transitions endpoint returns
* transitions by default, but we include the query param for compliance).
*/
async function getTransitions(issueKey) {
const res = await jiraGet(
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`
);
if (res.status === 200) {
return { ok: true, data: JSON.parse(res.body) };
}
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
}
/**
* Test connectivity — calls /rest/api/2/myself to verify credentials.
* This is a lightweight GET that returns the authenticated user.
*/
async function testConnection() {
try {
const res = await jiraGet('/rest/api/2/myself');
if (res.status === 200) {
const user = JSON.parse(res.body);
return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } };
}
return { ok: false, status: res.status, body: res.body };
} catch (err) {
return { ok: false, error: err.message };
}
}
module.exports = {
isConfigured,
jiraRequest,
jiraGet,
jiraPost,
jiraPut,
jiraDelete,
getIssue,
searchIssuesByKeys,
searchIssues,
createIssue,
updateIssue,
addComment,
transitionIssue,
getTransitions,
testConnection,
getRateLimitStatus,
DEFAULT_FIELDS,
JIRA_PROJECT_KEY,
JIRA_ISSUE_TYPE
};