feat: audit log, device tags, maintenance windows (v0.11.3 Beta)

- Audit log: middleware captures all write operations, paginated admin UI in Settings
- Device tags: full CRUD, device assignment, filter bar and chips on Devices page
- Maintenance windows: alert suppression during planned downtime, Settings management UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 15:33:15 -07:00
co-authored by Claude Sonnet 4.6
parent 6a4ab1fc5f
commit 49ab1b002e
16 changed files with 801 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.11.2-beta",
"version": "0.11.3-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+46
View File
@@ -291,6 +291,52 @@ CREATE TABLE IF NOT EXISTS credential_presets (
);
ALTER TABLE credential_presets ADD COLUMN IF NOT EXISTS allow_operator_use BOOLEAN NOT NULL DEFAULT TRUE;
-- Maintenance windows — suppress alerts for planned downtime
CREATE TABLE IF NOT EXISTS maintenance_windows (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
device_ids INTEGER[] NOT NULL DEFAULT '{}',
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
recurring_cron VARCHAR(100),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_active ON maintenance_windows(active, start_at, end_at);
-- Device tags
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS device_tags (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_device_tags_tag ON device_tags(tag_id);
-- Audit log — records all write operations performed by authenticated users
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER,
username VARCHAR(50),
method VARCHAR(10) NOT NULL,
path TEXT NOT NULL,
entity_type VARCHAR(50),
entity_id INTEGER,
summary TEXT,
ip_address VARCHAR(45),
status_code INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC);
-- Device availability (offline/online outage tracking)
CREATE TABLE IF NOT EXISTS device_availability (
id SERIAL PRIMARY KEY,
+8
View File
@@ -28,6 +28,9 @@ import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
import authRoutes from './routes/auth';
import auditLogRoutes from './routes/auditLog';
import tagsRoutes from './routes/tags';
import maintenanceWindowsRoutes from './routes/maintenanceWindows';
import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices';
import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients';
import eventsRoutes from './routes/events';
@@ -43,6 +46,7 @@ import alertsRoutes from './routes/alerts';
import wirelessRoutes from './routes/wireless';
import networkServicesRoutes from './routes/networkServices';
import credentialPresetsRoutes from './routes/credentialPresets';
import { auditMiddleware } from './middleware/auditMiddleware';
const app = express();
// nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP
@@ -184,6 +188,7 @@ app.use(express.urlencoded({ extended: true }));
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('combined'));
}
app.use(auditMiddleware);
// ─── Health Check ─────────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
@@ -207,6 +212,9 @@ app.use('/api/alerts', alertsRoutes);
app.use('/api/wireless', wirelessRoutes);
app.use('/api/network-services', networkServicesRoutes);
app.use('/api/credential-presets', credentialPresetsRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
// ─── Error Handler ────────────────────────────────────────────────────────────
app.use(errorHandler);
+60
View File
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express';
import { query } from '../config/database';
import { verifyToken } from './auth';
function extractEntity(path: string): { entityType: string | null; entityId: number | null } {
// e.g. /api/devices/42/interfaces → 'device', 42
const segments = path.replace(/^\/api\//, '').split('/');
const resourceMap: Record<string, string> = {
devices: 'device',
backups: 'backup',
clients: 'client',
events: 'event',
settings: 'settings',
alerts: 'alert',
topology: 'topology',
wireless: 'wireless',
'network-services': 'network_services',
'credential-presets': 'credential_preset',
'audit-log': 'audit_log',
users: 'user',
};
const entityType = resourceMap[segments[0]] ?? segments[0] ?? null;
const idRaw = segments[1];
const entityId = idRaw && /^\d+$/.test(idRaw) ? parseInt(idRaw, 10) : null;
return { entityType, entityId };
}
function extractUser(req: Request): { userId: number | null; username: string | null } {
if (req.user) return { userId: req.user.userId, username: req.user.username };
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
const payload = verifyToken(authHeader.slice(7));
return { userId: payload.userId, username: payload.username };
} catch { /* expired/invalid — record as anonymous */ }
}
return { userId: null, username: null };
}
export function auditMiddleware(req: Request, res: Response, next: NextFunction): void {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
next();
return;
}
res.on('finish', () => {
const { entityType, entityId } = extractEntity(req.path);
const { userId, username } = extractUser(req);
const summary = `${req.method} ${req.path}`;
const ip = (req.ip ?? '').replace(/^::ffff:/, '');
query(
`INSERT INTO audit_log (user_id, username, method, path, entity_type, entity_id, summary, ip_address, status_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[userId, username, req.method, req.path, entityType, entityId, summary, ip, res.statusCode]
).catch(() => {});
});
next();
}
+61
View File
@@ -0,0 +1,61 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
router.use(requireAdmin);
// GET /api/audit-log?page=1&limit=50&userId=&entityType=&search=&from=&to=
router.get('/', async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10));
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit || '50'), 10)));
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: unknown[] = [];
if (req.query.userId) {
params.push(parseInt(String(req.query.userId), 10));
conditions.push(`user_id = $${params.length}`);
}
if (req.query.entityType) {
params.push(String(req.query.entityType));
conditions.push(`entity_type = $${params.length}`);
}
if (req.query.search) {
params.push(`%${String(req.query.search)}%`);
conditions.push(`(summary ILIKE $${params.length} OR username ILIKE $${params.length} OR ip_address ILIKE $${params.length})`);
}
if (req.query.from) {
params.push(String(req.query.from));
conditions.push(`created_at >= $${params.length}`);
}
if (req.query.to) {
params.push(String(req.query.to));
conditions.push(`created_at <= $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [rows, countRows] = await Promise.all([
query<{
id: number; user_id: number | null; username: string | null;
method: string; path: string; entity_type: string | null;
entity_id: number | null; summary: string; ip_address: string | null;
status_code: number | null; created_at: string;
}>(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset]
),
query<{ count: string }>(`SELECT COUNT(*) as count FROM audit_log ${where}`, params),
]);
res.json({
rows,
total: parseInt(countRows[0]?.count || '0', 10),
page,
limit,
});
});
export default router;
+17 -1
View File
@@ -67,7 +67,23 @@ router.get('/', async (_req: Request, res: Response) => {
rack_name, rack_slot, created_at
FROM devices ORDER BY name ASC`
);
res.json(devices);
// Attach tags to each device
const tagRows = await query<{ device_id: number; id: number; name: string; color: string }>(
`SELECT dt.device_id, t.id, t.name, t.color
FROM device_tags dt JOIN tags t ON t.id = dt.tag_id`
);
const tagsByDevice: Record<number, { id: number; name: string; color: string }[]> = {};
for (const t of tagRows) {
if (!tagsByDevice[t.device_id]) tagsByDevice[t.device_id] = [];
tagsByDevice[t.device_id].push({ id: t.id, name: t.name, color: t.color });
}
const result = (devices as { id: number }[]).map((d) => ({
...d,
tags: tagsByDevice[d.id] ?? [],
}));
res.json(result);
});
// ─── Routers overview (router-type devices with route counts) ─────────────────
+88
View File
@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
export interface MaintenanceWindow {
id: number;
name: string;
device_ids: number[];
start_at: string;
end_at: string;
recurring_cron: string | null;
active: boolean;
created_at: string;
}
// Returns true if deviceId is currently in an active maintenance window
export async function isDeviceInMaintenance(deviceId: number): Promise<boolean> {
const rows = await query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM maintenance_windows
WHERE active = true
AND $1 = ANY(device_ids)
AND NOW() BETWEEN start_at AND end_at`,
[deviceId]
).catch(() => [{ count: '0' }]);
return parseInt(rows[0]?.count || '0', 10) > 0;
}
// GET /api/maintenance-windows
router.get('/', async (_req: Request, res: Response) => {
const rows = await query<MaintenanceWindow>(
`SELECT * FROM maintenance_windows ORDER BY start_at DESC`
);
res.json(rows);
});
// POST /api/maintenance-windows (admin)
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const { name, device_ids, start_at, end_at, recurring_cron } = req.body as {
name: string; device_ids: number[]; start_at: string; end_at: string; recurring_cron?: string;
};
if (!name?.trim() || !start_at || !end_at) {
res.status(400).json({ error: 'name, start_at, and end_at are required' });
return;
}
const rows = await query<MaintenanceWindow>(
`INSERT INTO maintenance_windows (name, device_ids, start_at, end_at, recurring_cron)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[name.trim(), device_ids ?? [], start_at, end_at, recurring_cron || null]
);
res.status(201).json(rows[0]);
});
// PUT /api/maintenance-windows/:id (admin)
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const { name, device_ids, start_at, end_at, recurring_cron, active } = req.body as Partial<MaintenanceWindow>;
const rows = await query<MaintenanceWindow>(
`UPDATE maintenance_windows SET
name = COALESCE($1, name),
device_ids = COALESCE($2, device_ids),
start_at = COALESCE($3, start_at),
end_at = COALESCE($4, end_at),
recurring_cron = COALESCE($5, recurring_cron),
active = COALESCE($6, active)
WHERE id = $7 RETURNING *`,
[name, device_ids, start_at, end_at, recurring_cron, active, req.params.id]
);
if (!rows[0]) { res.status(404).json({ error: 'Not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/maintenance-windows/:id (admin)
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM maintenance_windows WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
});
// GET /api/maintenance-windows/active — currently active windows (used by AlertService)
router.get('/active', requireWrite, async (_req: Request, res: Response) => {
const rows = await query<MaintenanceWindow>(
`SELECT * FROM maintenance_windows WHERE active = true AND NOW() BETWEEN start_at AND end_at`
);
res.json(rows);
});
export default router;
+85
View File
@@ -0,0 +1,85 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
// GET /api/tags — list all tags with device count
router.get('/', async (_req: Request, res: Response) => {
const tags = await query<{ id: number; name: string; color: string; device_count: string }>(
`SELECT t.id, t.name, t.color, COUNT(dt.device_id)::text AS device_count
FROM tags t
LEFT JOIN device_tags dt ON dt.tag_id = t.id
GROUP BY t.id ORDER BY t.name`
);
res.json(tags.map(t => ({ ...t, device_count: parseInt(t.device_count, 10) })));
});
// POST /api/tags — create tag (admin only)
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const { name, color } = req.body as { name: string; color?: string };
if (!name?.trim()) { res.status(400).json({ error: 'Tag name is required' }); return; }
const rows = await query<{ id: number; name: string; color: string }>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name.trim(), color || '#6366f1']
);
res.status(201).json(rows[0]);
});
// PUT /api/tags/:id — rename or recolor (admin only)
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const { name, color } = req.body as { name?: string; color?: string };
const rows = await query<{ id: number; name: string; color: string }>(
`UPDATE tags SET
name = COALESCE($1, name),
color = COALESCE($2, color)
WHERE id = $3 RETURNING id, name, color`,
[name?.trim() || null, color || null, req.params.id]
);
if (!rows[0]) { res.status(404).json({ error: 'Tag not found' }); return; }
res.json(rows[0]);
});
// DELETE /api/tags/:id (admin only)
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
res.json({ ok: true });
});
// POST /api/tags/:id/devices — assign tag to devices (operator+)
router.post('/:id/devices', requireWrite, async (req: Request, res: Response) => {
const tagId = parseInt(req.params.id, 10);
const { deviceIds, action } = req.body as { deviceIds: number[]; action: 'add' | 'remove' };
if (!Array.isArray(deviceIds) || deviceIds.length === 0) {
res.status(400).json({ error: 'deviceIds array required' });
return;
}
if (action === 'remove') {
await query(
`DELETE FROM device_tags WHERE tag_id = $1 AND device_id = ANY($2::int[])`,
[tagId, deviceIds]
);
} else {
for (const deviceId of deviceIds) {
await query(
`INSERT INTO device_tags (device_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[deviceId, tagId]
);
}
}
res.json({ ok: true });
});
// GET /api/tags/device/:deviceId — tags for a specific device
router.get('/device/:deviceId', async (req: Request, res: Response) => {
const tags = await query<{ id: number; name: string; color: string }>(
`SELECT t.id, t.name, t.color FROM tags t
JOIN device_tags dt ON dt.tag_id = t.id
WHERE dt.device_id = $1 ORDER BY t.name`,
[req.params.deviceId]
);
res.json(tags);
});
export default router;
+13
View File
@@ -73,6 +73,19 @@ export class AlertService {
const rule = await this.getRule(eventType);
if (!rule?.enabled) return;
// Maintenance window suppression — skip if device is in a currently active window
if (ctx.deviceId != null) {
const inMaintenance = await query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM maintenance_windows
WHERE active = true AND $1 = ANY(device_ids) AND NOW() BETWEEN start_at AND end_at`,
[ctx.deviceId]
).catch(() => [{ count: '0' }]);
if (parseInt(inMaintenance[0]?.count || '0', 10) > 0) {
console.log(`[AlertService] Suppressed ${eventType} for device ${ctx.deviceId} (maintenance window)`);
return;
}
}
// Cooldown check
const cooldownKey = ctx.cooldownKey ?? `${eventType}:${ctx.deviceId ?? 'global'}`;
const cooldownMs = (rule.cooldown_min ?? 15) * 60 * 1000;