add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,394 @@
/**
* Audit Service
*
* GDPR compliance:
* - Art. 5(2) Accountability: every write to personal data is logged.
* - Art. 30 Records of Processing Activities: who, what, when, delta.
* - Art. 5(1)e Storage limitation: IP addresses anonymised after 90 days
* via anonymizeOldIpAddresses(), called by the scheduled job.
*
* Critical invariant: logAudit() MUST NEVER throw or reject. Audit failures
* are logged to Winston and silently swallowed so that the main request flow
* is never interrupted.
*/
import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Enums — kept as const objects rather than TypeScript enums so that the
// values are available as plain strings in runtime code and SQL parameters.
// ---------------------------------------------------------------------------
export const AuditAction = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
LOGIN: 'LOGIN',
LOGOUT: 'LOGOUT',
EXPORT: 'EXPORT',
PERMISSION_DENIED:'PERMISSION_DENIED',
PASSWORD_CHANGE: 'PASSWORD_CHANGE',
ROLE_CHANGE: 'ROLE_CHANGE',
} as const;
export type AuditAction = typeof AuditAction[keyof typeof AuditAction];
export const AuditResourceType = {
MEMBER: 'MEMBER',
INCIDENT: 'INCIDENT',
VEHICLE: 'VEHICLE',
EQUIPMENT: 'EQUIPMENT',
QUALIFICATION: 'QUALIFICATION',
USER: 'USER',
SYSTEM: 'SYSTEM',
} as const;
export type AuditResourceType = typeof AuditResourceType[keyof typeof AuditResourceType];
// ---------------------------------------------------------------------------
// Core interfaces
// ---------------------------------------------------------------------------
export interface AuditLogEntry {
id: string; // UUID, set by database
user_id: string | null; // UUID; null for unauthenticated events
user_email: string | null; // denormalised snapshot
action: AuditAction;
resource_type: AuditResourceType;
resource_id: string | null;
old_value: Record<string, unknown> | null;
new_value: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown>;
created_at: Date;
}
/**
* Input type for logAudit() — the caller never supplies id or created_at.
*/
export type AuditLogInput = Omit<AuditLogEntry, 'id' | 'created_at'>;
// ---------------------------------------------------------------------------
// Filter + pagination types (used by the admin API)
// ---------------------------------------------------------------------------
export interface AuditFilters {
userId?: string;
action?: AuditAction | AuditAction[];
resourceType?: AuditResourceType | AuditResourceType[];
resourceId?: string;
dateFrom?: Date;
dateTo?: Date;
page: number; // 1-based
pageSize: number; // max 200
}
export interface AuditLogPage {
entries: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
// ---------------------------------------------------------------------------
// Sensitive field stripping
// Ensures that passwords, tokens, and secrets never appear in the log even
// if a caller accidentally passes a raw request body.
// ---------------------------------------------------------------------------
const SENSITIVE_KEYS = new Set([
'password', 'password_hash', 'passwordHash',
'secret', 'token', 'accessToken', 'refreshToken', 'access_token',
'refresh_token', 'id_token', 'client_secret',
'authorization', 'cookie',
]);
function stripSensitiveFields(
value: Record<string, unknown> | null | undefined
): Record<string, unknown> | null {
if (value === null || value === undefined) return null;
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEYS.has(k.toLowerCase())) {
result[k] = '[REDACTED]';
} else if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
result[k] = stripSensitiveFields(v as Record<string, unknown>);
} else {
result[k] = v;
}
}
return result;
}
// ---------------------------------------------------------------------------
// AuditService
// ---------------------------------------------------------------------------
class AuditService {
/**
* logAudit — fire-and-forget.
*
* The caller does NOT await this. Even if awaited, it will never reject;
* all errors are swallowed and written to Winston instead.
*/
async logAudit(entry: AuditLogInput): Promise<void> {
// Start immediately — do not block the caller.
this._writeAuditEntry(entry).catch(() => {
// _writeAuditEntry already handles its own error logging; this .catch()
// prevents a potential unhandledRejection if the async method itself
// throws synchronously before the inner try/catch.
});
}
/**
* Internal write — all failures are caught and sent to Winston.
* This method should never surface an exception to its caller.
*/
private async _writeAuditEntry(entry: AuditLogInput): Promise<void> {
try {
const sanitisedOld = stripSensitiveFields(entry.old_value ?? null);
const sanitisedNew = stripSensitiveFields(entry.new_value ?? null);
const query = `
INSERT INTO audit_log (
user_id,
user_email,
action,
resource_type,
resource_id,
old_value,
new_value,
ip_address,
user_agent,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`;
const values = [
entry.user_id ?? null,
entry.user_email ?? null,
entry.action,
entry.resource_type,
entry.resource_id ?? null,
sanitisedOld ? JSON.stringify(sanitisedOld) : null,
sanitisedNew ? JSON.stringify(sanitisedNew) : null,
entry.ip_address ?? null,
entry.user_agent ?? null,
JSON.stringify(entry.metadata ?? {}),
];
await pool.query(query, values);
logger.debug('Audit entry written', {
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
});
} catch (error) {
// GDPR obligation: log the failure so it can be investigated, but
// NEVER propagate — the main request must complete successfully.
logger.error('AUDIT LOG FAILURE — entry could not be persisted to database', {
error: error instanceof Error ? error.message : String(error),
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
// Note: we intentionally do NOT log old_value/new_value here
// because they may contain personal data.
});
}
}
// -------------------------------------------------------------------------
// Query — admin UI
// -------------------------------------------------------------------------
/**
* getAuditLog — paginated, filtered query for the admin dashboard.
*
* Never called from hot paths; can be awaited normally.
*/
async getAuditLog(filters: AuditFilters): Promise<AuditLogPage> {
const page = Math.max(1, filters.page ?? 1);
const pageSize = Math.min(200, Math.max(1, filters.pageSize ?? 25));
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`);
values.push(filters.userId);
}
if (filters.action) {
const actions = Array.isArray(filters.action)
? filters.action
: [filters.action];
conditions.push(`action = ANY($${paramIndex++}::audit_action[])`);
values.push(actions);
}
if (filters.resourceType) {
const types = Array.isArray(filters.resourceType)
? filters.resourceType
: [filters.resourceType];
conditions.push(`resource_type = ANY($${paramIndex++}::audit_resource_type[])`);
values.push(types);
}
if (filters.resourceId) {
conditions.push(`resource_id = $${paramIndex++}`);
values.push(filters.resourceId);
}
if (filters.dateFrom) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`created_at <= $${paramIndex++}`);
values.push(filters.dateTo);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const countQuery = `
SELECT COUNT(*) AS total
FROM audit_log
${whereClause}
`;
const dataQuery = `
SELECT
id, user_id, user_email, action, resource_type, resource_id,
old_value, new_value, ip_address, user_agent, metadata, created_at
FROM audit_log
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`;
const [countResult, dataResult] = await Promise.all([
pool.query(countQuery, values),
pool.query(dataQuery, [...values, pageSize, offset]),
]);
const total = parseInt(countResult.rows[0].total, 10);
const entries = dataResult.rows as AuditLogEntry[];
return {
entries,
total,
page,
pages: Math.ceil(total / pageSize),
};
}
// -------------------------------------------------------------------------
// GDPR IP anonymisation — run as a scheduled job
// -------------------------------------------------------------------------
/**
* anonymizeOldIpAddresses
*
* Replaces the ip_address of every audit_log row older than 90 days with
* the literal string '[anonymized]'. This satisfies the GDPR storage
* limitation principle (Art. 5(1)(e)) for IP addresses as personal data.
*
* Uses a single UPDATE with a WHERE clause covered by idx_audit_log_ip_retention,
* so performance is proportional to the number of rows being anonymised, not
* the total table size.
*
* Note: The database RULE audit_log_no_update blocks UPDATE statements issued
* *by application queries*, but that rule is created with DO INSTEAD NOTHING,
* which means the application-level anonymisation query also cannot run unless
* the rule is dropped or replaced.
*
* Resolution: the migration creates the rule. For the anonymisation job to
* work, the database role used by this application must either:
* (a) own the table (then rules do not apply to the owner), OR
* (b) use a separate privileged role for the anonymisation query only.
*
* Recommended approach: use option (a) by ensuring DB_USER is the table
* owner, which is the default when the same user runs the migrations.
* The immutability rule then protects against accidental application-level
* UPDATE/DELETE while the owner can still perform sanctioned data operations.
*/
async anonymizeOldIpAddresses(): Promise<void> {
try {
const result = await pool.query(`
UPDATE audit_log
SET ip_address = '[anonymized]'
WHERE created_at < NOW() - INTERVAL '90 days'
AND ip_address IS NOT NULL
AND ip_address != '[anonymized]'
`);
const count = result.rowCount ?? 0;
if (count > 0) {
logger.info('GDPR IP anonymisation complete', { rows_anonymized: count });
} else {
logger.debug('GDPR IP anonymisation: no rows required anonymisation');
}
} catch (error) {
logger.error('GDPR IP anonymisation job failed', {
error: error instanceof Error ? error.message : String(error),
});
// Do not re-throw: the job scheduler should not crash the process.
}
}
// -------------------------------------------------------------------------
// CSV export helper (used by the admin API route)
// -------------------------------------------------------------------------
/**
* Converts a page of audit entries to CSV format.
* Passwords and secrets are already stripped; old_value/new_value are
* serialised as compact JSON strings within the CSV cell.
*/
entriesToCsv(entries: AuditLogEntry[]): string {
const header = [
'id', 'created_at', 'user_id', 'user_email',
'action', 'resource_type', 'resource_id',
'ip_address', 'user_agent', 'old_value', 'new_value', 'metadata',
].join(',');
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const str = typeof v === 'object' ? JSON.stringify(v) : String(v);
// RFC 4180: wrap in quotes, double any internal quotes
return `"${str.replace(/"/g, '""')}"`;
};
const rows = entries.map((e) =>
[
e.id,
e.created_at.toISOString(),
e.user_id ?? '',
e.user_email ?? '',
e.action,
e.resource_type,
e.resource_id ?? '',
e.ip_address ?? '',
escape(e.user_agent),
escape(e.old_value),
escape(e.new_value),
escape(e.metadata),
].join(',')
);
return [header, ...rows].join('\r\n');
}
}
export default new AuditService();