mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
feat: ( audit ) resolve the actor from the session and audit all backoffice mutations
This commit is contained in:
@@ -21,6 +21,7 @@ export interface ActingUserClaims {
|
||||
name?: ActingUserName | string | null;
|
||||
username?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
/** The minimal Express request shape needed to reach the authenticated user. */
|
||||
@@ -34,6 +35,8 @@ export interface ActingUser {
|
||||
id: string;
|
||||
/** Human-readable name, denormalized alongside the id. */
|
||||
name: string;
|
||||
/** Contact number, denormalized alongside the id. Absent on tokens that omit it. */
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,11 @@ export function resolveActingUser(req: RequestWithActingUser): ActingUser | null
|
||||
const id = claims?.id ?? claims?.sub;
|
||||
if (!id) return null;
|
||||
|
||||
return { id, name: resolveActingUserName(claims) };
|
||||
return {
|
||||
id,
|
||||
name: resolveActingUserName(claims),
|
||||
phone: claims?.phoneNumber?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveActingUserName(claims: ActingUserClaims | undefined): string {
|
||||
|
||||
124
apps/edr-passenger-api/src/common/audit-snapshot.ts
Normal file
124
apps/edr-passenger-api/src/common/audit-snapshot.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Building the `oldData` / `newData` payloads on an audit row.
|
||||
*
|
||||
* Audit rows are retained for a year and readable by anyone with `audit:view`, so they must
|
||||
* carry enough to explain a change and nothing more. Dumping a whole Prisma row is the easy
|
||||
* mistake: it drags along payment tokens, contact details and identity documents that the
|
||||
* reader never needed. `snapshot()` inverts the default — you list what to keep.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Words that must never reach an audit row, whatever the caller asked for.
|
||||
*
|
||||
* Matched against the key's own words rather than as raw substrings — a substring test would
|
||||
* redact `shipping` for containing "pin" and miss nothing in exchange.
|
||||
*/
|
||||
const DENIED_WORDS = new Set([
|
||||
'password',
|
||||
'passcode',
|
||||
'secret',
|
||||
'token',
|
||||
'otp',
|
||||
'pin',
|
||||
'authorization',
|
||||
'auth',
|
||||
'apikey',
|
||||
'passport',
|
||||
'nationalid',
|
||||
'fayda',
|
||||
'ssn',
|
||||
]);
|
||||
|
||||
/** Value written in place of a denied field, so the reader knows something was withheld. */
|
||||
const REDACTED = '[redacted]';
|
||||
|
||||
/**
|
||||
* Splits `paymentToken`, `payment_token` and `PAYMENT_TOKEN` alike into ['payment', 'token'],
|
||||
* then reports whether any word is denied.
|
||||
*/
|
||||
function isDeniedKey(key: string): boolean {
|
||||
const words = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.map((w) => w.toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
// `nationalId` splits into ['national','id'], so check the joined form too.
|
||||
return words.some((w) => DENIED_WORDS.has(w)) || DENIED_WORDS.has(words.join(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks `keys` off `entity`, dropping anything absent and redacting anything sensitive.
|
||||
*
|
||||
* Returns `undefined` for a null/undefined entity so a missing "before" state simply omits
|
||||
* `oldData` rather than storing `{}`.
|
||||
*/
|
||||
export function snapshot<T extends object>(
|
||||
entity: T | null | undefined,
|
||||
keys: readonly (keyof T & string)[],
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!entity) return undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
const value = (entity as Record<string, unknown>)[key];
|
||||
if (value === undefined) continue;
|
||||
out[key] = isDeniedKey(key) ? REDACTED : normalize(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same guarantees as {@link snapshot} for a payload assembled by hand rather than picked off an
|
||||
* entity — the sweeps and gate branches build their context inline.
|
||||
*/
|
||||
export function auditPayload(
|
||||
data: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === undefined) continue;
|
||||
out[key] = isDeniedKey(key) ? REDACTED : normalize(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma hands back `Date` and `Decimal` objects. `JSON.stringify` would render a Decimal as
|
||||
* `{}`, so flatten both to something a reader can compare across two snapshots.
|
||||
*/
|
||||
function normalize(value: unknown): unknown {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value && typeof value === 'object' && typeof (value as any).toFixed === 'function') {
|
||||
return (value as any).toString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a before/after pair to only the fields that actually moved.
|
||||
*
|
||||
* Used where the "before" row is wide but the edit is narrow (a schedule status flip, a fare
|
||||
* price change) so the audit row shows the change rather than burying it.
|
||||
*/
|
||||
export function changedFields(
|
||||
before: Record<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
): { oldData?: Record<string, unknown>; newData?: Record<string, unknown> } {
|
||||
if (!before || !after) return { oldData: before, newData: after };
|
||||
|
||||
const oldData: Record<string, unknown> = {};
|
||||
const newData: Record<string, unknown> = {};
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (JSON.stringify(before[key]) === JSON.stringify(after[key])) continue;
|
||||
oldData[key] = before[key];
|
||||
newData[key] = after[key];
|
||||
}
|
||||
|
||||
return {
|
||||
oldData: Object.keys(oldData).length ? oldData : undefined,
|
||||
newData: Object.keys(newData).length ? newData : undefined,
|
||||
};
|
||||
}
|
||||
91
apps/edr-passenger-api/src/common/audit.actions.ts
Normal file
91
apps/edr-passenger-api/src/common/audit.actions.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The closed vocabulary for audit rows.
|
||||
*
|
||||
* `AuditLog.action` and `AuditLog.entityType` are plain `String` columns, so nothing at the
|
||||
* database level stops a caller inventing `created` or `schedule_updated`. These two maps are
|
||||
* the convention: uppercase, semantic, and imported rather than typed inline. The pre-existing
|
||||
* rows use `CREATE`/`UPDATE`/`DELETE`/`VERIFY`, so those keep their meaning and the rest extend
|
||||
* them — no historical row changes shape.
|
||||
*/
|
||||
|
||||
export const AUDIT_ACTIONS = {
|
||||
// ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
CREATE: 'CREATE',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
/** Undo of a soft delete (train restore, seat undo-remove, ticket restore). */
|
||||
RESTORE: 'RESTORE',
|
||||
|
||||
// ── Explicit state transitions ─────────────────────────────────────────────
|
||||
/** A status column moved. Prefer this over UPDATE when status is the point of the change. */
|
||||
STATUS_CHANGE: 'STATUS_CHANGE',
|
||||
CANCEL: 'CANCEL',
|
||||
|
||||
// ── Seats & coaches ────────────────────────────────────────────────────────
|
||||
BLOCK: 'BLOCK',
|
||||
UNBLOCK: 'UNBLOCK',
|
||||
ASSIGN: 'ASSIGN',
|
||||
UNASSIGN: 'UNASSIGN',
|
||||
|
||||
// ── Charges & money ────────────────────────────────────────────────────────
|
||||
WAIVE: 'WAIVE',
|
||||
PAY: 'PAY',
|
||||
REFUND: 'REFUND',
|
||||
/** Re-sending a payment link — no state change, but it re-exposes a payment token. */
|
||||
RESEND: 'RESEND',
|
||||
|
||||
// ── Gate ───────────────────────────────────────────────────────────────────
|
||||
BOARD: 'BOARD',
|
||||
BOARD_DENIED: 'BOARD_DENIED',
|
||||
/**
|
||||
* Retained for backward-compatible filtering of gate rows written before BOARD existed.
|
||||
* New code uses BOARD / BOARD_DENIED.
|
||||
*/
|
||||
VERIFY: 'VERIFY',
|
||||
|
||||
// ── Bulk / sweeps ──────────────────────────────────────────────────────────
|
||||
BULK_CREATE: 'BULK_CREATE',
|
||||
BULK_UPDATE: 'BULK_UPDATE',
|
||||
SYNC: 'SYNC',
|
||||
IMPORT: 'IMPORT',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
/**
|
||||
* Entity names as they appear in `AuditLog.entityType`. These mirror the Prisma model names so
|
||||
* a reader can go from an audit row straight to the table, and they back the backoffice
|
||||
* Audit Logs filter dropdown.
|
||||
*/
|
||||
export const AUDIT_ENTITIES = {
|
||||
// Master data
|
||||
Station: 'Station',
|
||||
Train: 'Train',
|
||||
Coach: 'Coach',
|
||||
CoachType: 'CoachType',
|
||||
Seat: 'Seat',
|
||||
SeatClass: 'SeatClass',
|
||||
Route: 'Route',
|
||||
RouteStop: 'RouteStop',
|
||||
RouteCoachTemplate: 'RouteCoachTemplate',
|
||||
Schedule: 'Schedule',
|
||||
ScheduleFare: 'ScheduleFare',
|
||||
CoachAssignment: 'CoachAssignment',
|
||||
|
||||
// Finance / tariff
|
||||
FareRule: 'FareRule',
|
||||
RouteFareRule: 'RouteFareRule',
|
||||
SegmentFareRule: 'SegmentFareRule',
|
||||
Currency: 'Currency',
|
||||
Payment: 'Payment',
|
||||
PaymentMethod: 'PaymentMethod',
|
||||
SupplementaryCharge: 'SupplementaryCharge',
|
||||
ExcessBaggageCharge: 'ExcessBaggageCharge',
|
||||
BaggageAllowance: 'BaggageAllowance',
|
||||
|
||||
// Operations
|
||||
Ticket: 'Ticket',
|
||||
Booking: 'Booking',
|
||||
} as const;
|
||||
|
||||
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];
|
||||
224
apps/edr-passenger-api/src/common/audit.service.spec.ts
Normal file
224
apps/edr-passenger-api/src/common/audit.service.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { AuditService } from './audit.service';
|
||||
import { snapshot, auditPayload, changedFields } from './audit-snapshot';
|
||||
|
||||
/**
|
||||
* The audit primitive is the reason ~47 of the 57 existing call sites wrote a NULL actor: it
|
||||
* held the request (for IP/user-agent) but never read `request.user`. These pin the fix — the
|
||||
* actor is resolved from the guarded session, callers cannot be trusted to pass it, and a
|
||||
* logging failure never propagates into the business operation it describes.
|
||||
*/
|
||||
describe('AuditService', () => {
|
||||
const REQUEST = {
|
||||
user: {
|
||||
id: 'iam-user-1',
|
||||
name: { en: 'Abebe Kebede', am: 'አበበ ከበደ' },
|
||||
username: 'abebe.k',
|
||||
email: 'abebe@edr.et',
|
||||
phoneNumber: '+251911223344',
|
||||
},
|
||||
headers: {
|
||||
'x-forwarded-for': '10.1.2.3, 172.16.0.1',
|
||||
'user-agent': 'Mozilla/5.0 (Backoffice)',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
};
|
||||
|
||||
const build = (request?: any) => {
|
||||
const create = jest.fn().mockResolvedValue({});
|
||||
const prisma = { auditLog: { create, count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() } };
|
||||
return { service: new AuditService(prisma as any, request), create, prisma };
|
||||
};
|
||||
|
||||
const written = (create: jest.Mock) => create.mock.calls[0][0].data;
|
||||
|
||||
describe('actor resolution', () => {
|
||||
it('takes the actor from the authenticated request when the caller omits one', async () => {
|
||||
const { service, create } = build(REQUEST);
|
||||
await service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' });
|
||||
|
||||
expect(written(create)).toMatchObject({
|
||||
iamUserId: 'iam-user-1',
|
||||
userName: 'Abebe Kebede',
|
||||
userPhone: '+251911223344',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores nothing the request says — a body cannot smuggle in a different actor', async () => {
|
||||
const { service, create } = build({
|
||||
...REQUEST,
|
||||
// A request body field never reaches AuditService; only `request.user` does.
|
||||
body: { validatorId: 'someone-else', waivedBy: 'not-me' },
|
||||
});
|
||||
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-1' });
|
||||
|
||||
expect(written(create).iamUserId).toBe('iam-user-1');
|
||||
});
|
||||
|
||||
it('lets a system path pass its own actor explicitly', async () => {
|
||||
const { service, create } = build(REQUEST);
|
||||
await service.log({
|
||||
action: 'STATUS_CHANGE',
|
||||
entityType: 'Schedule',
|
||||
entityId: 'sc-1',
|
||||
userId: 'SYSTEM',
|
||||
userName: 'Reconciliation cron',
|
||||
});
|
||||
|
||||
expect(written(create)).toMatchObject({ iamUserId: 'SYSTEM', userName: 'Reconciliation cron' });
|
||||
});
|
||||
|
||||
it('records a null actor outside an HTTP request rather than inventing one', async () => {
|
||||
const { service, create } = build(undefined);
|
||||
await service.log({ action: 'PAY', entityType: 'ExcessBaggageCharge', entityId: 'c-1' });
|
||||
|
||||
expect(written(create)).toMatchObject({ iamUserId: null, userName: null, userPhone: null });
|
||||
});
|
||||
|
||||
it('falls back through the name shapes IAM actually returns', async () => {
|
||||
const { service, create } = build({ user: { sub: 'legacy-id', username: 'gate.agent' }, headers: {} });
|
||||
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-2' });
|
||||
|
||||
expect(written(create)).toMatchObject({ iamUserId: 'legacy-id', userName: 'gate.agent' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('request metadata', () => {
|
||||
it('captures the client IP and user-agent', async () => {
|
||||
const { service, create } = build(REQUEST);
|
||||
await service.log({ action: 'UPDATE', entityType: 'Train', entityId: 'tr-1' });
|
||||
|
||||
expect(written(create)).toMatchObject({
|
||||
ipAddress: '10.1.2.3',
|
||||
userAgent: 'Mozilla/5.0 (Backoffice)',
|
||||
});
|
||||
});
|
||||
|
||||
it('still writes the row when the request carries no headers', async () => {
|
||||
// Queue consumers resolve this service with a bare stub; reading through `headers`
|
||||
// unguarded used to throw and silently drop the whole row.
|
||||
const { service, create } = build({ user: { id: 'iam-user-1' }, ip: '10.9.9.9' });
|
||||
await service.log({ action: 'PAY', entityType: 'SupplementaryCharge', entityId: 'sc-1' });
|
||||
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(written(create)).toMatchObject({ ipAddress: '10.9.9.9', userAgent: '' });
|
||||
});
|
||||
|
||||
it('leaves IP and user-agent blank with no request at all', async () => {
|
||||
const { service, create } = build(undefined);
|
||||
await service.log({ action: 'DELETE', entityType: 'Seat', entityId: 's-1' });
|
||||
|
||||
expect(written(create)).toMatchObject({ ipAddress: '', userAgent: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure containment', () => {
|
||||
it('swallows a write failure instead of breaking the operation it describes', async () => {
|
||||
const create = jest.fn().mockRejectedValue(new Error('db down'));
|
||||
const service = new AuditService({ auditLog: { create } } as any, REQUEST);
|
||||
|
||||
await expect(
|
||||
service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
const withRows = () => {
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
const count = jest.fn().mockResolvedValue(0);
|
||||
const prisma = { auditLog: { findMany, count, create: jest.fn() } };
|
||||
return { service: new AuditService(prisma as any, REQUEST), findMany, count };
|
||||
};
|
||||
|
||||
it('caps the page size so a caller cannot ask for the whole table', async () => {
|
||||
const { service, findMany } = withRows();
|
||||
await service.getLogs({ limit: 5000 });
|
||||
expect(findMany.mock.calls[0][0].take).toBe(200);
|
||||
});
|
||||
|
||||
it('defaults to the newest 50 rows', async () => {
|
||||
const { service, findMany } = withRows();
|
||||
await service.getLogs();
|
||||
expect(findMany.mock.calls[0][0]).toMatchObject({
|
||||
take: 50,
|
||||
skip: 0,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by exact actor and a date range', async () => {
|
||||
const { service, findMany } = withRows();
|
||||
await service.getLogs({ iamUserId: 'iam-user-1', from: '2026-08-01', to: '2026-08-18' });
|
||||
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.iamUserId).toBe('iam-user-1');
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-08-01'));
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-08-18'));
|
||||
});
|
||||
|
||||
it('ignores an unparseable date instead of returning nothing', async () => {
|
||||
const { service, findMany } = withRows();
|
||||
await service.getLogs({ from: 'not-a-date' });
|
||||
expect(findMany.mock.calls[0][0].where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searches the denormalized actor name and phone, not just ids', async () => {
|
||||
const { service, findMany } = withRows();
|
||||
await service.getLogs({ search: 'abebe' });
|
||||
|
||||
const fields = findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]);
|
||||
expect(fields).toEqual(
|
||||
expect.arrayContaining(['entityId', 'iamUserId', 'userName', 'userPhone']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit snapshot helpers', () => {
|
||||
it('keeps only the listed fields', () => {
|
||||
const entity = { id: 'x', name: 'Dire Dawa', code: 'DD', internalNote: 'do not log' };
|
||||
expect(snapshot(entity, ['name', 'code'])).toEqual({ name: 'Dire Dawa', code: 'DD' });
|
||||
});
|
||||
|
||||
it('drops absent fields rather than writing undefined', () => {
|
||||
expect(snapshot({ name: 'X', code: undefined } as any, ['name', 'code'])).toEqual({ name: 'X' });
|
||||
});
|
||||
|
||||
it('returns undefined for a missing entity so oldData is simply omitted', () => {
|
||||
expect(snapshot(null, ['name'] as any)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts credentials even when a caller asks for them', () => {
|
||||
const charge = { paymentToken: 'tok-live-1', amountMinor: 1000 } as any;
|
||||
expect(snapshot(charge, ['paymentToken', 'amountMinor'])).toEqual({
|
||||
paymentToken: '[redacted]',
|
||||
amountMinor: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts on word boundaries, not raw substrings', () => {
|
||||
// "shipping" contains the letters of "pin"; it is not a credential.
|
||||
const row = { shippingNote: 'leave at gate', otp: '4530', national_id: 'ETH-1' } as any;
|
||||
expect(auditPayload(row)).toEqual({
|
||||
shippingNote: 'leave at gate',
|
||||
otp: '[redacted]',
|
||||
national_id: '[redacted]',
|
||||
});
|
||||
});
|
||||
|
||||
it('flattens dates so two snapshots can be compared', () => {
|
||||
const at = new Date('2026-08-18T06:00:00.000Z');
|
||||
expect(snapshot({ departureAt: at } as any, ['departureAt'])).toEqual({
|
||||
departureAt: '2026-08-18T06:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reduces a before/after pair to the fields that actually moved', () => {
|
||||
const before = { status: 'SCHEDULED', departureTime: '08:00' };
|
||||
const after = { status: 'CANCELLED', departureTime: '08:00' };
|
||||
expect(changedFields(before, after)).toEqual({
|
||||
oldData: { status: 'SCHEDULED' },
|
||||
newData: { status: 'CANCELLED' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,38 @@
|
||||
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { AuditAction } from './audit.actions';
|
||||
import { resolveActingUser } from './acting-user';
|
||||
|
||||
export interface AuditLogInput {
|
||||
/**
|
||||
* Overrides the actor. Leave it out on any request-scoped path — `log()` resolves the
|
||||
* authenticated IAM user from the request, which is the only source a caller cannot forge.
|
||||
*/
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userPhone?: string;
|
||||
/** See `AUDIT_ACTIONS`. Widened to `string` so the 57 pre-existing call sites still compile. */
|
||||
action: AuditAction | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
oldData?: any;
|
||||
newData?: any;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
search?: string;
|
||||
action?: string;
|
||||
entityType?: string;
|
||||
/** Exact IAM user id — "everything this staff member did". */
|
||||
iamUserId?: string;
|
||||
/** Inclusive lower bound on `createdAt`. */
|
||||
from?: Date | string;
|
||||
/** Inclusive upper bound on `createdAt`. */
|
||||
to?: Date | string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
@@ -10,21 +42,21 @@ export class AuditService {
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
async log(input: {
|
||||
userId?: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
oldData?: any;
|
||||
newData?: any;
|
||||
}) {
|
||||
async log(input: AuditLogInput) {
|
||||
try {
|
||||
// The actor is read off the guarded request rather than trusted from the caller, so a
|
||||
// request body can never decide whose name lands in the trail. Callers may still pass
|
||||
// `userId` explicitly for system paths (crons, queue consumers) that have no request.
|
||||
const actor = this.request ? resolveActingUser(this.request) : null;
|
||||
|
||||
const ipAddress = this.getIpAddress();
|
||||
const userAgent = this.getUserAgent();
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
iamUserId: input.userId,
|
||||
iamUserId: input.userId ?? actor?.id ?? null,
|
||||
userName: input.userName ?? actor?.name ?? null,
|
||||
userPhone: input.userPhone ?? actor?.phone ?? null,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
@@ -41,11 +73,14 @@ export class AuditService {
|
||||
}
|
||||
|
||||
private getIpAddress(): string {
|
||||
if (!this.request) return '';
|
||||
|
||||
// `headers` is absent on non-HTTP contexts (queue consumers resolve this service with a
|
||||
// bare request stub); reading through it unguarded used to throw and silently drop the row.
|
||||
const headers = this.request?.headers;
|
||||
if (!headers) return this.request?.ip || '';
|
||||
|
||||
return (
|
||||
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
this.request.headers['x-real-ip'] ||
|
||||
headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
headers['x-real-ip'] ||
|
||||
this.request.connection?.remoteAddress ||
|
||||
this.request.socket?.remoteAddress ||
|
||||
this.request.ip ||
|
||||
@@ -57,13 +92,15 @@ export class AuditService {
|
||||
return this.request?.headers?.['user-agent'] || '';
|
||||
}
|
||||
|
||||
async getLogs(filters: any = {}) {
|
||||
async getLogs(filters: AuditLogFilters = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ iamUserId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ userName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ userPhone: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -75,6 +112,15 @@ export class AuditService {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
if (filters.iamUserId) {
|
||||
where.iamUserId = filters.iamUserId;
|
||||
}
|
||||
|
||||
const createdAt = this.dateRange(filters.from, filters.to);
|
||||
if (createdAt) {
|
||||
where.createdAt = createdAt;
|
||||
}
|
||||
|
||||
const limit = Math.min(filters.limit ?? 50, 200);
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
@@ -94,4 +140,23 @@ export class AuditService {
|
||||
async getLog(id: string) {
|
||||
return this.prisma.auditLog.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** Builds a Prisma date filter, ignoring bounds that don't parse. */
|
||||
private dateRange(from?: Date | string, to?: Date | string) {
|
||||
const range: { gte?: Date; lte?: Date } = {};
|
||||
|
||||
const lower = this.toDate(from);
|
||||
if (lower) range.gte = lower;
|
||||
|
||||
const upper = this.toDate(to);
|
||||
if (upper) range.lte = upper;
|
||||
|
||||
return Object.keys(range).length ? range : undefined;
|
||||
}
|
||||
|
||||
private toDate(value?: Date | string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user