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:
co-authored by
Claude Sonnet 4.6
parent
6a4ab1fc5f
commit
49ab1b002e
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) ─────────────────
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user