diff --git a/.kiro/steering/workflow.md b/.kiro/steering/workflow.md new file mode 100644 index 0000000..9e5c5bf --- /dev/null +++ b/.kiro/steering/workflow.md @@ -0,0 +1,27 @@ +# Workflow & Context Gathering + +## Specs First + +Before making changes to any feature area, **always check `.kiro/specs/` for related spec folders first**. Specs contain the original requirements, design decisions, architecture diagrams, data models, and task breakdowns that informed the implementation. They provide critical context about: + +- Why a feature was built a certain way +- What data models and API contracts were agreed upon +- What correctness properties must hold +- What edge cases were considered + +Even if the code has evolved since the spec was written, the spec is the starting point for understanding intent. + +## Spec Folder Structure + +Each spec folder typically contains: + +- `requirements.md` — user stories and acceptance criteria +- `design.md` — architecture, data models, API contracts, error handling +- `tasks.md` — implementation task breakdown with completion status + +## When to Check Specs + +- Fixing bugs in a feature area — check the spec to understand intended behavior +- Adding to an existing feature — check the spec to understand design constraints +- Investigating unexpected behavior — the spec documents what "correct" looks like +- Refactoring — the spec documents which properties must be preserved diff --git a/backend/routes/ivantiArchive.js b/backend/routes/ivantiArchive.js index f22dd20..eb63375 100644 --- a/backend/routes/ivantiArchive.js +++ b/backend/routes/ivantiArchive.js @@ -33,9 +33,25 @@ function createIvantiArchiveRouter() { // All routes require authentication router.use(requireAuth()); - // GET / — List archive records with optional state filtering + /** + * GET / + * List archive records with optional state and teams filtering. + * + * @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED. + * When state=ACTIVE, returns live open findings from ivanti_findings instead of archives. + * When state=CLOSED, includes both CLOSED and CLOSED_GONE records. + * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). + * Filters results to findings whose bu_ownership contains one of the specified teams. + * + * @response {object} 200 + * { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number } + * @response {object} 400 - Invalid state parameter + * { error: string } + * @response {object} 500 - Database error + * { error: string } + */ router.get('/', async (req, res) => { - const { state } = req.query; + const { state, teams } = req.query; if (state && !VALID_STATES.includes(state)) { return res.status(400).json({ @@ -43,17 +59,64 @@ function createIvantiArchiveRouter() { }); } - try { - let query = 'SELECT * FROM ivanti_finding_archives'; - const params = []; - let paramIndex = 1; + // Parse teams filter into ILIKE patterns + const teamPatterns = teams + ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') + : []; - if (state) { - query += ` WHERE current_state = $${paramIndex++}`; - params.push(state); + try { + // ACTIVE state comes from ivanti_findings (live open findings), not archives + if (state === 'ACTIVE') { + let activeQuery = `SELECT id, id AS finding_id, title AS finding_title, host_name, ip_address, + 'ACTIVE' AS current_state, severity AS last_severity, + synced_at AS first_archived_at, synced_at AS last_transition_at, synced_at AS created_at + FROM ivanti_findings WHERE state = 'open'`; + const activeParams = []; + let activeIdx = 1; + if (teamPatterns.length > 0) { + activeQuery += ` AND bu_ownership ILIKE ANY($${activeIdx++}::text[])`; + activeParams.push(teamPatterns); + } + activeQuery += ` ORDER BY severity DESC NULLS LAST LIMIT 200`; + const { rows: activeRows } = await pool.query(activeQuery, activeParams); + const archives = activeRows.map(r => ({ ...r, related_active: null })); + return res.json({ archives, total: archives.length }); } - query += ' ORDER BY last_transition_at DESC'; + // For non-ACTIVE states, query archives with optional BU join + let query, params = [], paramIndex = 1; + + if (teamPatterns.length > 0) { + // JOIN with ivanti_findings to filter by bu_ownership + query = `SELECT a.* FROM ivanti_finding_archives a + INNER JOIN ivanti_findings f ON a.finding_id = f.id + WHERE f.bu_ownership ILIKE ANY($${paramIndex++}::text[])`; + params.push(teamPatterns); + if (state) { + if (state === 'CLOSED') { + query += ` AND a.current_state IN ($${paramIndex++}, $${paramIndex++})`; + params.push('CLOSED', 'CLOSED_GONE'); + } else { + query += ` AND a.current_state = $${paramIndex++}`; + params.push(state); + } + } + } else { + query = 'SELECT * FROM ivanti_finding_archives'; + if (state) { + if (state === 'CLOSED') { + query += ` WHERE current_state IN ($${paramIndex++}, $${paramIndex++})`; + params.push('CLOSED', 'CLOSED_GONE'); + } else { + query += ` WHERE current_state = $${paramIndex++}`; + params.push(state); + } + } + } + + query += teamPatterns.length > 0 + ? ' ORDER BY a.last_transition_at DESC' + : ' ORDER BY last_transition_at DESC'; const { rows: archives } = await pool.query(query, params); @@ -82,27 +145,60 @@ function createIvantiArchiveRouter() { } }); - // GET /stats — Summary counts by lifecycle state + /** + * GET /stats + * Summary counts of archive records grouped by lifecycle state. + * + * @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG'). + * Filters counts to findings whose bu_ownership contains one of the specified teams. + * + * @response {object} 200 + * { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } + * @response {object} 500 - Database error + * { error: string } + */ router.get('/stats', async (req, res) => { try { - const { rows } = await pool.query( - `SELECT current_state, COUNT(*) as count - FROM ivanti_finding_archives - GROUP BY current_state` - ); + const { teams } = req.query; + const teamPatterns = teams + ? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') + : []; + + let archiveQuery, archiveParams = []; + if (teamPatterns.length > 0) { + archiveQuery = `SELECT a.current_state, COUNT(*) as count + FROM ivanti_finding_archives a + INNER JOIN ivanti_findings f ON a.finding_id = f.id + WHERE f.bu_ownership ILIKE ANY($1::text[]) + GROUP BY a.current_state`; + archiveParams = [teamPatterns]; + } else { + archiveQuery = `SELECT current_state, COUNT(*) as count + FROM ivanti_finding_archives + GROUP BY current_state`; + } + + const { rows } = await pool.query(archiveQuery, archiveParams); const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 }; for (const row of rows) { if (stats.hasOwnProperty(row.current_state)) { - stats[row.current_state] = parseInt(row.count); + stats[row.current_state] += parseInt(row.count); + } else if (row.current_state === 'CLOSED_GONE') { + stats.CLOSED += parseInt(row.count); } } - // ACTIVE = total live findings count - const countResult = await pool.query( - `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'` - ); + // ACTIVE = total live findings count (scoped by teams if provided) + let activeQuery, activeParams = []; + if (teamPatterns.length > 0) { + activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`; + activeParams = [teamPatterns]; + } else { + activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`; + } + const countResult = await pool.query(activeQuery, activeParams); stats.ACTIVE = parseInt(countResult.rows[0].total) || 0; const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED; @@ -114,7 +210,17 @@ function createIvantiArchiveRouter() { } }); - // GET /:findingId/history — Transition history for a specific archived finding + /** + * GET /:findingId/history + * Transition history for a specific archived finding. + * + * @param {string} findingId - The finding ID to look up in the archives. + * + * @response {object} 200 + * { finding_id: string, transitions: Array<{ id, archive_id, from_state, to_state, transitioned_at, reason }> } + * @response {object} 500 - Database error + * { error: string } + */ router.get('/:findingId/history', async (req, res) => { const { findingId } = req.params; diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js index c5a5d55..7d3dbec 100644 --- a/backend/routes/ivantiWorkflows.js +++ b/backend/routes/ivantiWorkflows.js @@ -154,7 +154,7 @@ async function readState() { try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ } return { - total: row.total || 0, + total: workflows.length, workflows, synced_at: row.synced_at, sync_status: row.sync_status, diff --git a/frontend/src/App.js b/frontend/src/App.js index b8811f6..d6984f4 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -167,7 +167,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001'; const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low']; export default function App() { - const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin } = useAuth(); + const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, getActiveTeamsParam, adminScope } = useAuth(); const [searchQuery, setSearchQuery] = useState(''); const [selectedVendor, setSelectedVendor] = useState('All Vendors'); const [selectedSeverity, setSelectedSeverity] = useState('All Severities'); @@ -402,7 +402,11 @@ export default function App() { setArchiveFilter(newFilter); if (newFilter) { setArchiveListLoading(true); - fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' }) + const teamsParam = getActiveTeamsParam(); + const url = teamsParam + ? `${API_BASE}/ivanti/archive?state=${newFilter}&teams=${encodeURIComponent(teamsParam)}` + : `${API_BASE}/ivanti/archive?state=${newFilter}`; + fetch(url, { credentials: 'include' }) .then(res => res.ok ? res.json() : Promise.reject()) .then(data => setArchiveList(data.archives || [])) .catch(() => setArchiveList([])) @@ -2357,12 +2361,12 @@ export default function App() { {/* Last synced line */}