mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
feat: audit logging, secrets at rest, and legacy cleanup (#205)
* feat: audit logging, secrets at rest, and legacy cleanup - Add Team Pro audit log: records all mutating API actions with user attribution, searchable timeline UI with filtering and pagination - Add AES-256-GCM encryption at rest for node API tokens via CryptoService - Drop 9 legacy SSH/TLS columns from nodes table (dead since v0.7) - Remove orphaned MaintenanceModal.tsx (dead code, never imported) - Add requireTeamPro backend guard for team-tier features - Add audit log cleanup (90-day retention) to MonitorService - Add docs page and navigation entry for audit log feature * fix: remove unused AUTH_TAG_LENGTH constant from CryptoService
This commit is contained in:
@@ -440,6 +440,82 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
authMiddleware(req, res, next);
|
||||
});
|
||||
|
||||
// Audit logging middleware — records all mutating API actions for Team Pro accountability.
|
||||
// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code.
|
||||
const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /stacks': 'Created stack',
|
||||
'DELETE /stacks': 'Deleted stack',
|
||||
'POST /compose/up': 'Deployed stack',
|
||||
'POST /compose/down': 'Stopped stack',
|
||||
'POST /compose/start': 'Started stack',
|
||||
'POST /compose/stop': 'Stopped stack',
|
||||
'POST /compose/restart': 'Restarted stack',
|
||||
'POST /compose/pull': 'Pulled stack images',
|
||||
'POST /system/prune': 'Pruned system resources',
|
||||
'POST /nodes': 'Added node',
|
||||
'PUT /nodes': 'Updated node',
|
||||
'DELETE /nodes': 'Deleted node',
|
||||
'POST /users': 'Created user',
|
||||
'DELETE /users': 'Deleted user',
|
||||
'PUT /users': 'Updated user',
|
||||
'POST /license/activate': 'Activated license',
|
||||
'POST /license/deactivate': 'Deactivated license',
|
||||
'POST /agents': 'Updated notification agent',
|
||||
'POST /webhooks': 'Created webhook',
|
||||
'PUT /webhooks': 'Updated webhook',
|
||||
'DELETE /webhooks': 'Deleted webhook',
|
||||
'PUT /settings': 'Updated settings',
|
||||
'POST /fleet/snapshot': 'Created fleet backup',
|
||||
'DELETE /fleet/snapshot': 'Deleted fleet backup',
|
||||
'POST /fleet/snapshot/restore': 'Restored fleet backup',
|
||||
};
|
||||
|
||||
function getAuditSummary(method: string, apiPath: string): string {
|
||||
// Try exact prefix matches from most specific to least
|
||||
const normalized = apiPath.replace(/^\//, '');
|
||||
for (const [pattern, summary] of Object.entries(AUDIT_ROUTE_SUMMARIES)) {
|
||||
const [pMethod, pPath] = pattern.split(' ');
|
||||
if (method === pMethod && normalized.startsWith(pPath.replace(/^\//, ''))) {
|
||||
// Extract resource name from path if available (e.g., /stacks/myapp → "myapp")
|
||||
const rest = normalized.slice(pPath.replace(/^\//, '').length).replace(/^\//, '');
|
||||
const resourceName = rest.split('/')[0];
|
||||
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
|
||||
}
|
||||
}
|
||||
return `${method} /api/${normalized}`;
|
||||
}
|
||||
|
||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const username = req.user?.username || 'unknown';
|
||||
const nodeId = req.nodeId ?? null;
|
||||
const ip = req.ip || req.headers['x-forwarded-for'] as string || '';
|
||||
const apiPath = req.path;
|
||||
|
||||
res.on('finish', () => {
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username,
|
||||
method: req.method,
|
||||
path: `/api${apiPath}`,
|
||||
status_code: res.statusCode,
|
||||
node_id: nodeId,
|
||||
ip_address: ip,
|
||||
summary: getAuditSummary(req.method, apiPath),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audit] Failed to write audit log:', err);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// --- License Routes (local-only, never proxied) ---
|
||||
|
||||
// Pro feature guard: returns false and sends 403 if not Pro tier.
|
||||
@@ -451,6 +527,20 @@ const requirePro = (_req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Team Pro feature guard: requires Pro tier with team variant.
|
||||
const requireTeamPro = (_req: Request, res: Response): boolean => {
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getTier() !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
if (ls.getVariant() !== 'team') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Team Pro.', code: 'TEAM_PRO_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required.', code: 'ADMIN_REQUIRED' });
|
||||
@@ -2715,6 +2805,28 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
// --- Audit Log Routes (Team Pro, local-only) ---
|
||||
|
||||
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
const username = req.query.username as string | undefined;
|
||||
const method = req.query.method as string | undefined;
|
||||
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
|
||||
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
|
||||
|
||||
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[AuditLog] Failed to fetch audit log:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch audit log' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- System Maintenance Routes (The System Janitor) ---
|
||||
|
||||
app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 16;
|
||||
const ENCRYPTED_PREFIX = 'enc:';
|
||||
|
||||
export class CryptoService {
|
||||
private static instance: CryptoService;
|
||||
private key: Buffer;
|
||||
|
||||
private constructor() {
|
||||
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
const keyPath = path.join(dataDir, 'encryption.key');
|
||||
|
||||
if (fs.existsSync(keyPath)) {
|
||||
this.key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'hex');
|
||||
} else {
|
||||
this.key = crypto.randomBytes(KEY_LENGTH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(keyPath, this.key.toString('hex'), { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): CryptoService {
|
||||
if (!CryptoService.instance) {
|
||||
CryptoService.instance = new CryptoService();
|
||||
}
|
||||
return CryptoService.instance;
|
||||
}
|
||||
|
||||
public encrypt(plaintext: string): string {
|
||||
if (!plaintext) return plaintext;
|
||||
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${ENCRYPTED_PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
public decrypt(ciphertext: string): string {
|
||||
if (!ciphertext || !this.isEncrypted(ciphertext)) return ciphertext;
|
||||
|
||||
const payload = ciphertext.slice(ENCRYPTED_PREFIX.length);
|
||||
const [ivHex, authTagHex, encryptedHex] = payload.split(':');
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
|
||||
}
|
||||
|
||||
public isEncrypted(value: string): boolean {
|
||||
return value.startsWith(ENCRYPTED_PREFIX);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { CryptoService } from './CryptoService';
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
@@ -96,6 +97,18 @@ export interface FleetSnapshotFile {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
username: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
node_id: number | null;
|
||||
ip_address: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -113,6 +126,7 @@ export class DatabaseService {
|
||||
this.initSchema();
|
||||
this.migrateJsonConfig(dataDir);
|
||||
this.migrateAdminToUsersTable();
|
||||
this.migrateEncryptNodeTokens();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -250,6 +264,21 @@ export class DatabaseService {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
node_id INTEGER,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -261,16 +290,11 @@ export class DatabaseService {
|
||||
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
|
||||
|
||||
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
|
||||
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
|
||||
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
|
||||
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_ca', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_cert', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_key', "TEXT DEFAULT ''");
|
||||
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
|
||||
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
|
||||
for (const col of legacyCols) {
|
||||
try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch { /* already dropped or never existed */ }
|
||||
}
|
||||
|
||||
// Initialize default global settings if they don't exist
|
||||
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
|
||||
@@ -331,6 +355,17 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateEncryptNodeTokens(): void {
|
||||
const crypto = CryptoService.getInstance();
|
||||
const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>;
|
||||
for (const row of rows) {
|
||||
if (!crypto.isEncrypted(row.api_token)) {
|
||||
const encrypted = crypto.encrypt(row.api_token);
|
||||
this.db.prepare('UPDATE nodes SET api_token = ? WHERE id = ?').run(encrypted, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(): Agent[] {
|
||||
@@ -511,32 +546,39 @@ export class DatabaseService {
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
private decryptNodeRow(row: any): Node {
|
||||
const crypto = CryptoService.getInstance();
|
||||
return {
|
||||
...row,
|
||||
is_default: row.is_default === 1,
|
||||
api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
|
||||
};
|
||||
}
|
||||
|
||||
public getNodes(): Node[] {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
|
||||
return stmt.all().map((row: any) => ({
|
||||
...row,
|
||||
is_default: row.is_default === 1
|
||||
}));
|
||||
return stmt.all().map((row: any) => this.decryptNodeRow(row));
|
||||
}
|
||||
|
||||
public getNode(id: number): Node | undefined {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
|
||||
const row = stmt.get(id) as any;
|
||||
if (!row) return undefined;
|
||||
return { ...row, is_default: row.is_default === 1 };
|
||||
return this.decryptNodeRow(row);
|
||||
}
|
||||
|
||||
public getDefaultNode(): Node | undefined {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
|
||||
const row = stmt.get() as any;
|
||||
if (!row) return undefined;
|
||||
return { ...row, is_default: row.is_default === 1 };
|
||||
return this.decryptNodeRow(row);
|
||||
}
|
||||
|
||||
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
|
||||
if (node.is_default) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const crypto = CryptoService.getInstance();
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
@@ -548,7 +590,7 @@ export class DatabaseService {
|
||||
'unknown',
|
||||
Date.now(),
|
||||
node.api_url || '',
|
||||
node.api_token || ''
|
||||
node.api_token ? crypto.encrypt(node.api_token) : ''
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -570,7 +612,10 @@ export class DatabaseService {
|
||||
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
|
||||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
|
||||
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
|
||||
if (updates.api_token !== undefined) {
|
||||
fields.push('api_token = ?');
|
||||
values.push(updates.api_token ? CryptoService.getInstance().encrypt(updates.api_token) : '');
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
@@ -777,4 +822,59 @@ export class DatabaseService {
|
||||
public getSnapshotCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0;
|
||||
}
|
||||
|
||||
// --- Audit Log ---
|
||||
|
||||
public insertAuditLog(entry: Omit<AuditLogEntry, 'id'>): void {
|
||||
this.db.prepare(
|
||||
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(entry.timestamp, entry.username, entry.method, entry.path, entry.status_code, entry.node_id, entry.ip_address, entry.summary);
|
||||
}
|
||||
|
||||
public getAuditLogs(filters: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
username?: string;
|
||||
method?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
} = {}): { entries: AuditLogEntry[]; total: number } {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (filters.username) {
|
||||
conditions.push('username = ?');
|
||||
params.push(filters.username);
|
||||
}
|
||||
if (filters.method) {
|
||||
conditions.push('method = ?');
|
||||
params.push(filters.method);
|
||||
}
|
||||
if (filters.from) {
|
||||
conditions.push('timestamp >= ?');
|
||||
params.push(filters.from);
|
||||
}
|
||||
if (filters.to) {
|
||||
conditions.push('timestamp <= ?');
|
||||
params.push(filters.to);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const total = (this.db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as { count: number })?.count || 0;
|
||||
const entries = this.db.prepare(
|
||||
`SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, limit, offset) as AuditLogEntry[];
|
||||
|
||||
return { entries, total };
|
||||
}
|
||||
|
||||
public cleanupOldAuditLogs(daysToKeep = 90): void {
|
||||
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||||
this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ export class MonitorService {
|
||||
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
|
||||
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
|
||||
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
|
||||
db.cleanupOldAuditLogs(90);
|
||||
} catch (e) {
|
||||
console.error('MonitorService: failed to cleanup old data', e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user