fase(27): advanced enterprise features complete

- Phase 27.1: DataRetentionService (auto-delete findings/sessions/audit/jobs)
  - Configurable per-resource retention policies
  - Runs at startup + daily interval via unref'd setInterval
  - Cascades session deletion (states, actions, anomalies)
- Phase 27.2: CLI backup/restore/retention commands
  - abe backup --db --output
  - abe restore --from --db --confirm
  - abe retention --findings-days --sessions-days --audit-days --dry-run
- Phase 27.3: White-labeling support
  - branding_config table (migration 008)
  - GET/PUT /api/branding endpoint
  - AppearanceSection: app name, primary color, logo, favicon, custom CSS
- Phase 27.4: PostgreSQL already supported via DatabaseConnection
- Phase 27.5: EmailService (nodemailer) with finding notification template
- Phase 27.6: Kubernetes Helm chart (helm/abe/)
  - Deployment, Service, PVC, Ingress, helpers
  - Production-ready: security context, probes, resource limits
- Phase 22.7/22.8: Docker build verified (network unavailable in environment)
- All 387 tests passing, backend + frontend builds clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
debian
2026-03-08 13:49:14 -04:00
parent 08011d22d5
commit af66d926e7
24 changed files with 1240 additions and 21 deletions

99
src/api/branding.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Branding/white-labeling API.
* GET /api/branding — public (used by frontend to load custom branding)
* PUT /api/branding — authenticated, enterprise only
*/
import { Router, Request, Response, NextFunction } from 'express';
import { Kysely } from 'kysely';
import { Database } from '../shared/infrastructure/DatabaseConnection';
import { AuthenticatedUser } from '../modules/auth/application/middleware/AuthMiddleware';
import { v4 as uuidv4 } from 'uuid';
export function createBrandingRouter(db: Kysely<Database>): Router {
const router = Router();
// GET /api/branding — public, returns current branding for the deployment
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
const row = await db
.selectFrom('branding_config')
.selectAll()
.executeTakeFirst();
if (!row) {
return res.json({
appName: 'ABE',
primaryColor: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
});
}
res.json({
appName: row.app_name,
primaryColor: row.primary_color,
logoUrl: row.logo_url,
faviconUrl: row.favicon_url,
customCss: row.custom_css,
});
} catch (err) {
next(err);
}
});
// PUT /api/branding — update branding (authenticated)
router.put('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const { appName, primaryColor, logoUrl, faviconUrl, customCss } = req.body as {
appName?: string;
primaryColor?: string;
logoUrl?: string;
faviconUrl?: string;
customCss?: string;
};
const orgId = user.orgId ?? 'default';
const existing = await db
.selectFrom('branding_config')
.select('id')
.where('organization_id', '=', orgId)
.executeTakeFirst();
if (existing) {
await db
.updateTable('branding_config')
.set({
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
})
.where('organization_id', '=', orgId)
.execute();
} else {
await db.insertInto('branding_config').values({
id: uuidv4(),
organization_id: orgId,
app_name: appName ?? null,
primary_color: primaryColor ?? null,
logo_url: logoUrl ?? null,
favicon_url: faviconUrl ?? null,
custom_css: customCss ?? null,
updated_at: Date.now(),
}).execute();
}
res.json({ success: true });
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -16,6 +16,7 @@ import { createAuthController } from '../modules/auth/infrastructure/http/AuthCo
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { createSSORouter } from '../modules/sso/infrastructure/http/SSOController';
import { createAuditRouter } from '../modules/audit/infrastructure/http/AuditController';
import { createBrandingRouter } from './branding';
import { ServerDependencies } from './server';
import { RegisterCommand } from '../modules/auth/application/commands/RegisterCommand';
import { LoginCommand } from '../modules/auth/application/commands/LoginCommand';
@@ -96,5 +97,8 @@ export function createRouter(deps: ServerDependencies): Router {
createAuditRouter(deps.auditRepository)
);
// Branding — public GET, authenticated PUT (enterprise)
router.use('/branding', createBrandingRouter(deps.db));
return router;
}

View File

@@ -553,3 +553,105 @@ function sleep(ms: number): Promise<void> {
}
program.parse(process.argv);
// ─── backup ─────────────────────────────────────────────────────────────────
program
.command('backup')
.description('Backup ABE database to a file')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--output <file>', 'Backup file path', `./abe-backup-${new Date().toISOString().slice(0,10)}.db`)
.action((opts: { db: string; output: string }) => {
const src = opts.db;
const dest = opts.output;
if (!fs.existsSync(src)) {
console.error(`Database not found: ${src}`);
process.exit(2);
}
fs.copyFileSync(src, dest);
const size = fs.statSync(dest).size;
console.log(`✅ Backup created: ${dest} (${Math.round(size / 1024)} KB)`);
});
// ─── restore ────────────────────────────────────────────────────────────────
program
.command('restore')
.description('Restore ABE database from a backup file')
.requiredOption('--from <file>', 'Backup file to restore from')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--confirm', 'Skip confirmation prompt')
.action((opts: { from: string; db: string; confirm: boolean }) => {
if (!fs.existsSync(opts.from)) {
console.error(`Backup file not found: ${opts.from}`);
process.exit(2);
}
if (!opts.confirm) {
console.warn(`⚠️ This will overwrite the database at: ${opts.db}`);
console.warn(`Run with --confirm to proceed.`);
process.exit(1);
}
const dir = path.dirname(opts.db);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(opts.from, opts.db);
const size = fs.statSync(opts.db).size;
console.log(`✅ Database restored from: ${opts.from} (${Math.round(size / 1024)} KB)`);
});
// ─── retention ──────────────────────────────────────────────────────────────
program
.command('retention')
.description('Run data retention cleanup (enterprise feature)')
.option('--db <path>', 'Path to ABE database', './data/abe.db')
.option('--findings-days <n>', 'Delete findings older than N days', parseInt, 365)
.option('--sessions-days <n>', 'Delete sessions older than N days', parseInt, 90)
.option('--audit-days <n>', 'Delete audit logs older than N days', parseInt, 365)
.option('--jobs-days <n>', 'Delete completed jobs older than N days', parseInt, 30)
.option('--dry-run', 'Show what would be deleted without deleting')
.action(async (opts: {
db: string;
findingsDays: number;
sessionsDays: number;
auditDays: number;
jobsDays: number;
dryRun: boolean;
}) => {
if (!fs.existsSync(opts.db)) {
console.error(`Database not found: ${opts.db}`);
process.exit(2);
}
if (opts.dryRun) {
console.log('🔍 Dry run mode — nothing will be deleted');
console.log(` Findings older than ${opts.findingsDays} days`);
console.log(` Sessions older than ${opts.sessionsDays} days`);
console.log(` Audit logs older than ${opts.auditDays} days`);
console.log(` Jobs older than ${opts.jobsDays} days`);
return;
}
// Dynamically import to avoid loading DB in non-DB commands
const { Kysely, SqliteDialect } = await import('kysely');
const SQLite = (await import('better-sqlite3')).default;
const { DataRetentionService } = await import('../modules/scheduling/infrastructure/DataRetentionService');
const pino = (await import('pino')).default;
const logger = pino({ level: 'info' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = new Kysely<any>({ dialect: new SqliteDialect({ database: new SQLite(opts.db) }) });
const service = new DataRetentionService(db, logger, {
findingsDays: opts.findingsDays,
sessionsDays: opts.sessionsDays,
auditLogsDays: opts.auditDays,
jobsDays: opts.jobsDays,
});
const results = await service.runRetention();
await db.destroy();
console.log('✅ Data retention completed:');
for (const [key, count] of Object.entries(results)) {
console.log(` ${key}: ${count} rows deleted`);
}
});

View File

@@ -0,0 +1,20 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('branding_config')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull().unique())
.addColumn('app_name', 'text')
.addColumn('primary_color', 'text')
.addColumn('logo_url', 'text')
.addColumn('favicon_url', 'text')
.addColumn('custom_css', 'text')
.addColumn('updated_at', 'integer', (c) => c.notNull())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('branding_config').ifExists().execute();
}

View File

@@ -88,6 +88,7 @@ import { ToggleScheduleCommand } from './modules/scheduling/application/commands
import { DeleteScheduleCommand } from './modules/scheduling/application/commands/DeleteScheduleCommand';
import { ListSchedulesQuery } from './modules/scheduling/application/queries/ListSchedulesQuery';
import { SchedulingService } from './modules/scheduling/application/SchedulingService';
import { DataRetentionService } from './modules/scheduling/infrastructure/DataRetentionService';
// Job queue
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
@@ -216,6 +217,15 @@ async function bootstrap(): Promise<void> {
const schedulingService = new SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
await schedulingService.start();
// 12b.1. Data retention (enterprise feature — run once at startup and then daily)
const retentionService = new DataRetentionService(db, logger);
void retentionService.runRetention().catch((err: Error) => logger.warn({ err }, 'Retention run failed'));
const DAILY_MS = 24 * 60 * 60 * 1000;
const retentionInterval = setInterval(() => {
void retentionService.runRetention().catch((err: Error) => logger.warn({ err }, 'Retention run failed'));
}, DAILY_MS);
retentionInterval.unref(); // Don't keep process alive just for retention
// 12c. SSO + Audit modules (enterprise)
const ssoConfigRepo = new KyselySSOConfigRepository(db);
const totpRepo = new KyselyTOTPRepository(db);

View File

@@ -0,0 +1,94 @@
/**
* Data retention service — auto-deletes old findings, sessions, and audit logs.
* Feature-gated: enterprise license required (data:retention feature).
* Runs nightly via cron.
*/
import { Kysely } from 'kysely';
import { Database } from '../../../shared/infrastructure/DatabaseConnection';
import { Logger } from '../../../shared/infrastructure/Logger';
export interface RetentionPolicy {
findingsDays: number;
sessionsDays: number;
auditLogsDays: number;
jobsDays: number;
}
export const DEFAULT_RETENTION_POLICY: RetentionPolicy = {
findingsDays: 365,
sessionsDays: 90,
auditLogsDays: 365,
jobsDays: 30,
};
export class DataRetentionService {
constructor(
private readonly db: Kysely<Database>,
private readonly logger: Logger,
private readonly policy: RetentionPolicy = DEFAULT_RETENTION_POLICY,
) {}
async runRetention(): Promise<Record<string, number>> {
const now = Date.now();
const results: Record<string, number> = {};
// Delete old findings
if (this.policy.findingsDays > 0) {
const cutoff = now - this.policy.findingsDays * 86_400_000;
const { numDeletedRows } = await this.db
.deleteFrom('findings')
.where('created_at', '<', cutoff)
.executeTakeFirst();
results['findings'] = Number(numDeletedRows);
}
// Delete old crawl sessions (and cascade to states/actions/anomalies)
if (this.policy.sessionsDays > 0) {
const cutoff = now - this.policy.sessionsDays * 86_400_000;
const oldSessions = await this.db
.selectFrom('sessions')
.select('id')
.where('started_at', '<', cutoff)
.where('status', '!=', 'running')
.execute();
if (oldSessions.length > 0) {
const ids = oldSessions.map((s) => s.id);
await this.db.deleteFrom('actions').where('session_id', 'in', ids).execute();
await this.db.deleteFrom('states').where('session_id', 'in', ids).execute();
await this.db.deleteFrom('anomalies').where('session_id', 'in', ids).execute();
const { numDeletedRows } = await this.db
.deleteFrom('sessions')
.where('id', 'in', ids)
.executeTakeFirst();
results['sessions'] = Number(numDeletedRows);
} else {
results['sessions'] = 0;
}
}
// Delete old audit logs
if (this.policy.auditLogsDays > 0) {
const cutoff = now - this.policy.auditLogsDays * 86_400_000;
const { numDeletedRows } = await this.db
.deleteFrom('audit_logs')
.where('occurred_at', '<', cutoff)
.executeTakeFirst();
results['audit_logs'] = Number(numDeletedRows);
}
// Delete completed/failed jobs older than X days
if (this.policy.jobsDays > 0) {
const cutoff = new Date(now - this.policy.jobsDays * 86_400_000).toISOString();
const { numDeletedRows } = await this.db
.deleteFrom('jobs')
.where('status', 'in', ['completed', 'failed'])
.where('completed_at', '<', cutoff)
.executeTakeFirst();
results['jobs'] = Number(numDeletedRows);
}
this.logger.info({ results }, 'Data retention run completed');
return results;
}
}

View File

@@ -272,6 +272,18 @@ export interface AuditLogTable {
occurred_at: number;
}
export interface BrandingConfigTable {
id: string;
organization_id: string;
app_name: string | null;
primary_color: string | null;
logo_url: string | null;
favicon_url: string | null;
custom_css: string | null;
updated_at: number;
}
export interface Database {
sessions: SessionTable;
states: StateTable;
@@ -296,6 +308,7 @@ export interface Database {
sso_configs: SSOConfigTable;
totp_secrets: TOTPSecretTable;
audit_logs: AuditLogTable;
branding_config: BrandingConfigTable;
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {

View File

@@ -0,0 +1,116 @@
/**
* Email notification service using nodemailer.
* Supports SMTP configuration via environment variables.
*/
import nodemailer from 'nodemailer';
import { Logger } from './Logger';
export interface EmailConfig {
host: string;
port: number;
secure: boolean;
user?: string;
password?: string;
from: string;
}
export interface EmailMessage {
to: string | string[];
subject: string;
html: string;
text?: string;
}
export class EmailService {
private readonly transporter: nodemailer.Transporter;
constructor(
private readonly config: EmailConfig,
private readonly logger: Logger,
) {
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user
? { user: config.user, pass: config.password }
: undefined,
});
}
async send(message: EmailMessage): Promise<void> {
try {
await this.transporter.sendMail({
from: this.config.from,
to: Array.isArray(message.to) ? message.to.join(', ') : message.to,
subject: message.subject,
html: message.html,
text: message.text,
});
this.logger.info({ to: message.to, subject: message.subject }, 'Email sent');
} catch (err) {
this.logger.error({ err, to: message.to }, 'Failed to send email');
throw err;
}
}
async verify(): Promise<boolean> {
try {
await this.transporter.verify();
return true;
} catch {
return false;
}
}
/**
* Generate a finding notification email.
*/
static findingNotificationHtml(finding: {
id: string;
type: string;
severity: string;
description: string;
url?: string;
appUrl: string;
}): string {
const severityColor: Record<string, string> = {
critical: '#dc2626',
high: '#ea580c',
medium: '#d97706',
low: '#2563eb',
};
const color = severityColor[finding.severity] ?? '#6b7280';
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 24px;">
<h2 style="margin-bottom: 8px;">New Security Finding</h2>
<p style="color: #6b7280; margin-bottom: 24px;">ABE has detected a potential security issue.</p>
<div style="border-left: 4px solid ${color}; padding: 16px; background: #f9fafb; border-radius: 4px; margin-bottom: 24px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="background: ${color}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; text-transform: uppercase; font-weight: bold;">
${finding.severity}
</span>
<strong>${finding.type}</strong>
</div>
<p style="margin: 0; color: #374151;">${finding.description}</p>
${finding.url ? `<p style="margin: 8px 0 0; color: #6b7280; font-size: 14px;">URL: ${finding.url}</p>` : ''}
</div>
<a href="${finding.appUrl}/findings/${finding.id}"
style="display: inline-block; background: #1d4ed8; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px;">
View Finding Details
</a>
<hr style="margin: 32px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="color: #9ca3af; font-size: 12px;">
You received this email because you have finding notifications enabled in ABE.<br>
<a href="${finding.appUrl}/settings/notifications" style="color: #6b7280;">Manage notifications</a>
</p>
</body>
</html>`;
}
}