395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
/**
|
|
* 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();
|