Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
src/lib/audit.ts
Normal file
116
src/lib/audit.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { auditLogs } from '@/lib/db/schema';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export type AuditAction =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'archive'
|
||||
| 'restore'
|
||||
| 'merge'
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'permission_denied'
|
||||
| 'revert';
|
||||
|
||||
export interface AuditLogParams {
|
||||
/** Null for system-generated events. */
|
||||
userId: string | null;
|
||||
/** Null for system-level events not tied to a port. */
|
||||
portId: string | null;
|
||||
action: AuditAction;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fieldChanged?: string;
|
||||
oldValue?: Record<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = new Set([
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'credentials_enc',
|
||||
'token',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Masks sensitive field values to prevent PII or secrets from being stored
|
||||
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
|
||||
*
|
||||
* Strings are replaced with a partial mask — first 2 chars + *** + last 2 chars.
|
||||
*/
|
||||
export function maskSensitiveFields(
|
||||
data?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!data) return undefined;
|
||||
const masked = { ...data };
|
||||
for (const key of Object.keys(masked)) {
|
||||
if (SENSITIVE_FIELDS.has(key) && typeof masked[key] === 'string') {
|
||||
const val = masked[key] as string;
|
||||
masked[key] = val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a field-level diff between two records.
|
||||
* Returns one entry per changed field with the old and new values.
|
||||
*/
|
||||
export function diffFields(
|
||||
oldRecord: Record<string, unknown>,
|
||||
newRecord: Record<string, unknown>,
|
||||
): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
|
||||
const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
|
||||
for (const key of Object.keys(newRecord)) {
|
||||
if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) {
|
||||
changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts an audit log entry into the database.
|
||||
*
|
||||
* This function NEVER throws — errors are caught and logged so that an audit
|
||||
* failure never rolls back or disrupts the parent operation.
|
||||
*/
|
||||
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
await db.insert(auditLogs).values({
|
||||
portId: params.portId,
|
||||
userId: params.userId,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
fieldChanged: params.fieldChanged ?? null,
|
||||
oldValue: maskSensitiveFields(params.oldValue) ?? null,
|
||||
newValue: maskSensitiveFields(params.newValue) ?? null,
|
||||
metadata: params.metadata ?? null,
|
||||
ipAddress: params.ipAddress,
|
||||
userAgent: params.userAgent,
|
||||
});
|
||||
} catch (err) {
|
||||
// Strip old/new values from the log to avoid secondary exposure of the data
|
||||
// that just failed to persist.
|
||||
logger.error(
|
||||
{
|
||||
err,
|
||||
audit: {
|
||||
userId: params.userId,
|
||||
portId: params.portId,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
},
|
||||
},
|
||||
'Failed to write audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user