// routes/archerTemplates.js const express = require('express'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); // Section fields and their max length const SECTION_FIELDS = [ 'environment_overview', 'segmentation', 'mitigating_controls', 'additional_info', 'charter_network_banner', 'data_classification', 'charter_network', 'additional_access_list' ]; const SECTION_MAX_LENGTH = 10000; function createArcherTemplatesRouter() { const router = express.Router(); // --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) --- /** * GET /api/archer-templates/hierarchy/vendors * * Returns a sorted array of distinct vendor names across all templates. * * @returns {string[]} 200 - Array of vendor names sorted alphabetically * @returns {object} 500 - { error: 'Internal server error' } */ router.get('/hierarchy/vendors', requireAuth(), async (req, res) => { try { const { rows } = await pool.query( 'SELECT DISTINCT vendor FROM archer_templates ORDER BY vendor ASC' ); res.json(rows.map(r => r.vendor)); } catch (err) { console.error('Error fetching template vendors:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /api/archer-templates/hierarchy/platforms * * Returns a sorted array of distinct platform names for a given vendor. * * @query {string} vendor - (required) The vendor to filter platforms by * @returns {string[]} 200 - Array of platform names sorted alphabetically * @returns {object} 400 - { error: 'vendor query parameter is required' } * @returns {object} 500 - { error: 'Internal server error' } */ router.get('/hierarchy/platforms', requireAuth(), async (req, res) => { const { vendor } = req.query; if (!vendor) { return res.status(400).json({ error: 'vendor query parameter is required' }); } try { const { rows } = await pool.query( 'SELECT DISTINCT platform FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) ORDER BY platform ASC', [vendor] ); res.json(rows.map(r => r.platform)); } catch (err) { console.error('Error fetching template platforms:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /api/archer-templates/hierarchy/models * * Returns a sorted array of distinct model names for a given vendor and platform. * * @query {string} vendor - (required) The vendor to filter by * @query {string} platform - (required) The platform to filter by * @returns {string[]} 200 - Array of model names sorted alphabetically * @returns {object} 400 - { error: 'Missing required query parameters: ...' } * @returns {object} 500 - { error: 'Internal server error' } */ router.get('/hierarchy/models', requireAuth(), async (req, res) => { const { vendor, platform } = req.query; const missing = []; if (!vendor) missing.push('vendor'); if (!platform) missing.push('platform'); if (missing.length > 0) { return res.status(400).json({ error: `Missing required query parameters: ${missing.join(', ')}` }); } try { const { rows } = await pool.query( 'SELECT DISTINCT model FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) ORDER BY model ASC', [vendor, platform] ); res.json(rows.map(r => r.model)); } catch (err) { console.error('Error fetching template models:', err); res.status(500).json({ error: 'Internal server error' }); } }); // --- Core CRUD endpoints --- /** * POST /api/archer-templates * * Creates a new Archer template with vendor/platform/model hierarchy and section content. * Requires Admin or Standard_User group. * * @body {string} vendor - (required) Vendor name, 1-100 chars after trim * @body {string} platform - (required) Platform name, 1-100 chars after trim * @body {string} model - (required) Model name, 1-100 chars after trim * @body {string} [environment_overview] - Section content, max 10,000 chars * @body {string} [segmentation] - Section content, max 10,000 chars * @body {string} [mitigating_controls] - Section content, max 10,000 chars * @body {string} [additional_info] - Section content, max 10,000 chars * @body {string} [charter_network_banner] - Section content, max 10,000 chars * @body {string} [data_classification] - Section content, max 10,000 chars * @body {string} [charter_network] - Section content, max 10,000 chars * @body {string} [additional_access_list] - Section content, max 10,000 chars * @returns {object} 201 - The created template record (all columns) * @returns {object} 400 - { error: 'validation message' } * @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' } * @returns {object} 500 - { error: 'Internal server error' } */ router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { vendor, platform, model } = req.body; // Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim const errors = []; for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) { if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) { errors.push(`${field} is required`); } else if (value.trim().length > 100) { errors.push(`${field} must be 100 characters or fewer`); } } if (errors.length > 0) { return res.status(400).json({ error: errors.join('; ') }); } // Validate section fields — max 10,000 chars each, default to empty string const sectionValues = {}; for (const field of SECTION_FIELDS) { const val = req.body[field]; if (val !== undefined && val !== null && typeof val === 'string') { if (val.length > SECTION_MAX_LENGTH) { return res.status(400).json({ error: `${field} must be 10,000 characters or fewer` }); } sectionValues[field] = val; } else { sectionValues[field] = ''; } } try { const { rows } = await pool.query( `INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ vendor.trim(), platform.trim(), model.trim(), sectionValues.environment_overview, sectionValues.segmentation, sectionValues.mitigating_controls, sectionValues.additional_info, sectionValues.charter_network_banner, sectionValues.data_classification, sectionValues.charter_network, sectionValues.additional_access_list, req.user.id ] ); // Fire-and-forget audit log logAudit({ userId: req.user.id, username: req.user.username, action: 'template_created', entityType: 'archer_template', entityId: String(rows[0].id), details: { vendor: vendor.trim(), platform: platform.trim(), model: model.trim() }, ipAddress: req.ip }); res.status(201).json(rows[0]); } catch (err) { if (err.code === '23505') { return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' }); } console.error('Error creating template:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /api/archer-templates * * Lists all templates with optional search and exact-match filters. * Results are sorted by vendor, platform, model (ascending). * * @query {string} [search] - Substring search across vendor, platform, and model (ILIKE) * @query {string} [vendor] - Exact-match filter on vendor (case-insensitive) * @query {string} [platform] - Exact-match filter on platform (case-insensitive) * @query {string} [model] - Exact-match filter on model (case-insensitive) * @returns {object[]} 200 - Array of template records sorted by vendor/platform/model * @returns {object} 500 - { error: 'Internal server error' } */ router.get('/', requireAuth(), async (req, res) => { const { search, vendor, platform, model } = req.query; let query = 'SELECT * FROM archer_templates WHERE 1=1'; const params = []; let paramIndex = 1; // Search — ILIKE substring match across vendor, platform, model const trimmedSearch = search ? search.trim() : ''; if (trimmedSearch.length > 0) { query += ` AND (vendor ILIKE $${paramIndex} OR platform ILIKE $${paramIndex} OR model ILIKE $${paramIndex})`; params.push(`%${trimmedSearch}%`); paramIndex++; } // Exact-match filters (case-insensitive via LOWER/TRIM) if (vendor) { query += ` AND LOWER(TRIM(vendor)) = LOWER(TRIM($${paramIndex}))`; params.push(vendor); paramIndex++; } if (platform) { query += ` AND LOWER(TRIM(platform)) = LOWER(TRIM($${paramIndex}))`; params.push(platform); paramIndex++; } if (model) { query += ` AND LOWER(TRIM(model)) = LOWER(TRIM($${paramIndex}))`; params.push(model); paramIndex++; } query += ' ORDER BY vendor ASC, platform ASC, model ASC'; try { const { rows } = await pool.query(query, params); res.json(rows); } catch (err) { console.error('Error fetching templates:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * POST /api/archer-templates/:id/clone * * Clones an existing template's section content into a new template with different * vendor/platform/model hierarchy values. Requires Admin or Standard_User group. * * @param {number} id - The ID of the source template to clone from * @body {string} vendor - (required) New vendor name, 1-100 chars after trim * @body {string} platform - (required) New platform name, 1-100 chars after trim * @body {string} model - (required) New model name, 1-100 chars after trim * @returns {object} 201 - The newly created cloned template record * @returns {object} 400 - { error: 'validation message' } * @returns {object} 404 - { error: 'Template not found' } * @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' } * @returns {object} 500 - { error: 'Internal server error' } */ router.post('/:id/clone', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { vendor, platform, model } = req.body; // Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim const errors = []; for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) { if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) { errors.push(`${field} is required`); } else if (value.trim().length > 100) { errors.push(`${field} must be 100 characters or fewer`); } } if (errors.length > 0) { return res.status(400).json({ error: errors.join('; ') }); } try { // Verify source template exists const { rows: sourceRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]); if (sourceRows.length === 0) { return res.status(404).json({ error: 'Template not found' }); } const source = sourceRows[0]; // INSERT copying all 8 section fields from source with new hierarchy values const { rows } = await pool.query( `INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ vendor.trim(), platform.trim(), model.trim(), source.environment_overview, source.segmentation, source.mitigating_controls, source.additional_info, source.charter_network_banner, source.data_classification, source.charter_network, source.additional_access_list, req.user.id ] ); // Fire-and-forget audit log logAudit({ userId: req.user.id, username: req.user.username, action: 'template_cloned', entityType: 'archer_template', entityId: String(rows[0].id), details: { sourceId: Number(id), newId: rows[0].id, vendor: vendor.trim(), platform: platform.trim(), model: model.trim() }, ipAddress: req.ip }); res.status(201).json(rows[0]); } catch (err) { if (err.code === '23505') { return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' }); } console.error('Error cloning template:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * GET /api/archer-templates/:id * * Fetches a single template by its ID. * * @param {number} id - The template ID * @returns {object} 200 - The template record * @returns {object} 404 - { error: 'Template not found' } * @returns {object} 500 - { error: 'Internal server error' } */ router.get('/:id', requireAuth(), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]); if (rows.length === 0) { return res.status(404).json({ error: 'Template not found' }); } res.json(rows[0]); } catch (err) { console.error('Error fetching template:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * PUT /api/archer-templates/:id * * Updates an existing template. Supports partial updates — only provided fields are changed. * Always updates `updated_at` to NOW(). Requires Admin or Standard_User group. * * @param {number} id - The template ID to update * @body {string} [vendor] - New vendor name, 1-100 chars after trim * @body {string} [platform] - New platform name, 1-100 chars after trim * @body {string} [model] - New model name, 1-100 chars after trim * @body {string} [environment_overview] - Section content, max 10,000 chars * @body {string} [segmentation] - Section content, max 10,000 chars * @body {string} [mitigating_controls] - Section content, max 10,000 chars * @body {string} [additional_info] - Section content, max 10,000 chars * @body {string} [charter_network_banner] - Section content, max 10,000 chars * @body {string} [data_classification] - Section content, max 10,000 chars * @body {string} [charter_network] - Section content, max 10,000 chars * @body {string} [additional_access_list] - Section content, max 10,000 chars * @returns {object} 200 - The updated template record * @returns {object} 400 - { error: 'validation message' } * @returns {object} 404 - { error: 'Template not found' } * @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' } * @returns {object} 500 - { error: 'Internal server error' } */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { // Verify template exists const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]); if (existingRows.length === 0) { return res.status(404).json({ error: 'Template not found' }); } const existing = existingRows[0]; // Validate provided hierarchy fields const errors = []; const updatedFields = {}; const changedFieldNames = []; for (const field of ['vendor', 'platform', 'model']) { const value = req.body[field]; if (value !== undefined) { if (value === null || typeof value !== 'string' || value.trim().length === 0) { errors.push(`${field} is required`); } else if (value.trim().length > 100) { errors.push(`${field} must be 100 characters or fewer`); } else { updatedFields[field] = value.trim(); if (value.trim() !== existing[field]) { changedFieldNames.push(field); } } } } // Validate provided section fields for (const field of SECTION_FIELDS) { const val = req.body[field]; if (val !== undefined) { if (val !== null && typeof val === 'string') { if (val.length > SECTION_MAX_LENGTH) { errors.push(`${field} must be 10,000 characters or fewer`); } else { updatedFields[field] = val; if (val !== existing[field]) { changedFieldNames.push(field); } } } else { updatedFields[field] = ''; if ('' !== existing[field]) { changedFieldNames.push(field); } } } } if (errors.length > 0) { return res.status(400).json({ error: errors.join('; ') }); } // Check uniqueness if vendor/platform/model changed (excluding self) const newVendor = updatedFields.vendor || existing.vendor; const newPlatform = updatedFields.platform || existing.platform; const newModel = updatedFields.model || existing.model; if (updatedFields.vendor !== undefined || updatedFields.platform !== undefined || updatedFields.model !== undefined) { const { rows: conflictRows } = await pool.query( `SELECT id FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) AND LOWER(TRIM(model)) = LOWER(TRIM($3)) AND id != $4`, [newVendor, newPlatform, newModel, id] ); if (conflictRows.length > 0) { return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' }); } } // Build dynamic UPDATE SET clause for only provided fields const setClauses = []; const params = []; let paramIndex = 1; for (const [field, value] of Object.entries(updatedFields)) { setClauses.push(`${field} = $${paramIndex}`); params.push(value); paramIndex++; } // Always set updated_at = NOW() setClauses.push(`updated_at = NOW()`); // Execute update params.push(id); const { rows } = await pool.query( `UPDATE archer_templates SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, params ); // Fire-and-forget audit log logAudit({ userId: req.user.id, username: req.user.username, action: 'template_updated', entityType: 'archer_template', entityId: String(id), details: { changedFields: changedFieldNames }, ipAddress: req.ip }); res.json(rows[0]); } catch (err) { console.error('Error updating template:', err); res.status(500).json({ error: 'Internal server error' }); } }); /** * DELETE /api/archer-templates/:id * * Permanently deletes a template. Requires Admin or Standard_User group. * * @param {number} id - The template ID to delete * @returns {object} 200 - { message: 'Template deleted successfully' } * @returns {object} 404 - { error: 'Template not found' } * @returns {object} 500 - { error: 'Internal server error' } */ router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { // Verify template exists const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]); if (existingRows.length === 0) { return res.status(404).json({ error: 'Template not found' }); } const existing = existingRows[0]; // Delete the template await pool.query('DELETE FROM archer_templates WHERE id = $1', [id]); // Fire-and-forget audit log logAudit({ userId: req.user.id, username: req.user.username, action: 'template_deleted', entityType: 'archer_template', entityId: String(id), details: { vendor: existing.vendor, platform: existing.platform, model: existing.model }, ipAddress: req.ip }); res.json({ message: 'Template deleted successfully' }); } catch (err) { console.error('Error deleting template:', err); res.status(500).json({ error: 'Internal server error' }); } }); return router; } module.exports = createArcherTemplatesRouter;