add features
This commit is contained in:
394
backend/src/services/audit.service.ts
Normal file
394
backend/src/services/audit.service.ts
Normal 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();
|
||||
Reference in New Issue
Block a user