feat: ( audit ) resolve the actor from the session and audit all backoffice mutations

This commit is contained in:
Abubeker Yasin
2026-08-18 15:13:22 +03:00
parent 33100a31ae
commit 0eb64ad9a1
32 changed files with 3068 additions and 420 deletions

View File

@@ -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 {

View 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,
};
}

View 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];

View 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' },
});
});
});

View File

@@ -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;
}
}