Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,12 @@ PAYMENT_RAIL_FAILOVER=

# Payments confirmation pipeline (issue #1571)
# Shared secret for verifying the sandbox rail's webhook HMAC signature
# (computed over `timestamp + '.' + rawBody`, issue #1938).
PAYMENT_WEBHOOK_SECRET=change-me-in-every-environment
# Replay-protection window (seconds) for signed webhook timestamps. A
# delivery older than this is rejected and logged as `replay`. Defaults to
# 300 (5 minutes) when unset or invalid.
PAYMENT_WEBHOOK_TOLERANCE_SECONDS=300
# Bounded timeout (ms) for the synchronous verify-on-return fast path —
# a timeout falls back to the webhook/real-time channel, it never marks a
# payment CONFIRMED on its own.
Expand Down
8 changes: 8 additions & 0 deletions backend/src/admin-audit/admin-action-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export class AdminActionLog {
@Column({ type: 'text', nullable: true })
detail: string | null;

/** Caller IP captured from the request context, where one was available. */
@Column({ type: 'varchar', length: 64, nullable: true })
ip: string | null;

/** Caller User-Agent captured from the request context. */
@Column({ type: 'varchar', length: 512, name: 'user_agent', nullable: true })
userAgent: string | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
95 changes: 95 additions & 0 deletions backend/src/admin-audit/admin-action-log.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AdminActionLogService } from './admin-action-log.service';
import { AdminActionType } from './admin-action-type.enum';
import { runWithRequestContext } from '../common/request-context';
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';

function makeLogRepository(seed: any[] = []) {
const rows = [...seed];
Expand Down Expand Up @@ -43,6 +45,99 @@ describe('AdminActionLogService', () => {
expect(log.detail).toBe('DISTRIBUTION');
});

it('captures IP and User-Agent from the active request context', async () => {
const repo = makeLogRepository();
const service = new AdminActionLogService(repo as any);

const log = await runWithRequestContext(
{ requestId: 'req-1', ip: '203.0.113.7', userAgent: 'jest-agent' },
() =>
service.record({
actorId: 'actor-1',
action: AdminActionType.PAYMENT_VOID,
targetType: 'Payment',
}),
);

expect(log.ip).toBe('203.0.113.7');
expect(log.userAgent).toBe('jest-agent');
});

it('stores explicit metadata over the request context', async () => {
const repo = makeLogRepository();
const service = new AdminActionLogService(repo as any);

const log = await runWithRequestContext(
{ requestId: 'req-1', ip: '203.0.113.7', userAgent: 'context-agent' },
() =>
service.record({
actorId: 'actor-1',
action: AdminActionType.PAYMENT_VOID,
targetType: 'Payment',
ip: '198.51.100.4',
userAgent: 'explicit-agent',
}),
);

expect(log.ip).toBe('198.51.100.4');
expect(log.userAgent).toBe('explicit-agent');
});

it('records null metadata outside a request context', async () => {
const repo = makeLogRepository();
const service = new AdminActionLogService(repo as any);

const log = await service.record({
actorId: 'actor-1',
action: AdminActionType.PAYMENT_VOID,
targetType: 'Payment',
});

expect(log.ip).toBeNull();
expect(log.userAgent).toBeNull();
});

it('caps and range-filters the export query', async () => {
const repo = makeLogRepository();
const service = new AdminActionLogService(repo as any);
const from = new Date('2025-01-01T00:00:00Z');
const to = new Date('2025-02-01T00:00:00Z');

await service.listForExport({
from,
to,
action: AdminActionType.PAYMENT_VOID,
limit: 10000,
});

expect(repo.find).toHaveBeenCalledWith({
where: { action: 'payment_void', createdAt: Between(from, to) },
order: { createdAt: 'DESC' },
take: 10000,
});
});

it('supports an open-ended export range', async () => {
const repo = makeLogRepository();
const service = new AdminActionLogService(repo as any);
const from = new Date('2025-01-01T00:00:00Z');

await service.listForExport({ from, limit: 50 });
expect(repo.find).toHaveBeenLastCalledWith({
where: { createdAt: MoreThanOrEqual(from) },
order: { createdAt: 'DESC' },
take: 50,
});

const to = new Date('2025-02-01T00:00:00Z');
await service.listForExport({ to, limit: 50 });
expect(repo.find).toHaveBeenLastCalledWith({
where: { createdAt: LessThanOrEqual(to) },
order: { createdAt: 'DESC' },
take: 50,
});
});

it('lists logs newest first', async () => {
const repo = makeLogRepository([
{ id: 'a', action: 'payment_void', createdAt: new Date('2025-01-01T00:00:00Z') },
Expand Down
52 changes: 51 additions & 1 deletion backend/src/admin-audit/admin-action-log.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
Between,
FindOptionsWhere,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from 'typeorm';
import { AdminActionLog } from './admin-action-log.entity';
import { AdminActionType } from './admin-action-type.enum';
import { currentRequestMetadata } from '../common/request-context';

export interface RecordAdminActionInput {
actorId: string;
action: AdminActionType;
targetType: string;
targetId?: string | null;
detail?: string | null;
/**
* Explicit forensic metadata. When omitted the value is taken from the
* active request context (populated by RequestContextMiddleware), so
* controllers do not have to thread the request through every call.
*/
ip?: string | null;
userAgent?: string | null;
}

export interface ListForExportQuery {
from?: Date;
to?: Date;
action?: AdminActionType;
limit: number;
}

@Injectable()
Expand All @@ -20,12 +41,15 @@ export class AdminActionLogService {
) {}

async record(input: RecordAdminActionInput): Promise<AdminActionLog> {
const requestMetadata = currentRequestMetadata();
const entry = this.logRepository.create({
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId ?? null,
detail: input.detail ?? null,
ip: input.ip ?? requestMetadata.ip,
userAgent: input.userAgent ?? requestMetadata.userAgent,
});
return this.logRepository.save(entry);
}
Expand All @@ -37,6 +61,32 @@ export class AdminActionLogService {
});
}

/**
* Newest-first slice used by the CSV export. Applies the same action
* filter as `list`, plus an optional inclusive createdAt range and a hard
* row cap so a compliance export cannot be asked to materialise an
* unbounded result.
*/
listForExport(query: ListForExportQuery): Promise<AdminActionLog[]> {
const where: FindOptionsWhere<AdminActionLog> = {};
if (query.action) {
where.action = query.action;
}
if (query.from && query.to) {
where.createdAt = Between(query.from, query.to);
} else if (query.from) {
where.createdAt = MoreThanOrEqual(query.from);
} else if (query.to) {
where.createdAt = LessThanOrEqual(query.to);
}

return this.logRepository.find({
where,
order: { createdAt: 'DESC' },
take: query.limit,
});
}

async count(): Promise<number> {
return this.logRepository.count();
}
Expand Down
9 changes: 9 additions & 0 deletions backend/src/admin-audit/admin-action-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,13 @@ export enum AdminActionType {
* is the Payment id and `detail` is the required operator reason.
*/
PAYMENT_VOID = 'payment_void',

/**
* Recorded by `GET /admin/audit/actions/export` when an operator exports
* the audit trail as CSV. The target is `AdminActionLog`, `targetId` is
* null (the export is not scoped to one entry), and `detail` is
* `exported=<rowCount>` so the audit trail records that the trail itself
* was read.
*/
AUDIT_EXPORT = 'audit_export',
}
73 changes: 73 additions & 0 deletions backend/src/admin-audit/admin-audit-export.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
ADMIN_AUDIT_CSV_COLUMNS,
AdminAuditEntry,
escapeCsvValue,
exportAdminAuditToCsv,
} from './admin-audit-export.util';

function makeEntry(overrides: Partial<AdminAuditEntry> = {}): AdminAuditEntry {
return {
id: 'log-1',
actorId: 'actor-1',
action: 'payment_void',
targetType: 'Payment',
targetId: 'payment-1',
detail: 'reason',
ip: '203.0.113.7',
userAgent: 'jest-agent',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
...overrides,
};
}

describe('escapeCsvValue', () => {
it('neutralises formula-prefixed values', () => {
expect(escapeCsvValue('=1+1')).toBe("'=1+1");
expect(escapeCsvValue('+1')).toBe("'+1");
expect(escapeCsvValue('-1')).toBe("'-1");
expect(escapeCsvValue('@SUM(A1)')).toBe("'@SUM(A1)");
});

it('quotes values containing separators or quotes', () => {
expect(escapeCsvValue('a,b')).toBe('"a,b"');
expect(escapeCsvValue('say "hi"')).toBe('"say ""hi"""');
expect(escapeCsvValue('line\nbreak')).toBe('"line\nbreak"');
});

it('neutralises then quotes a formula that also contains a comma', () => {
expect(escapeCsvValue('=cmd,quoted')).toBe('"\'=cmd,quoted"');
});

it('renders null and undefined as empty cells', () => {
expect(escapeCsvValue(null)).toBe('');
expect(escapeCsvValue(undefined)).toBe('');
});

it('leaves ordinary values untouched', () => {
expect(escapeCsvValue('payment_void')).toBe('payment_void');
});
});

describe('exportAdminAuditToCsv', () => {
it('writes a stable header followed by one row per entry', () => {
const csv = exportAdminAuditToCsv([makeEntry()]);
const lines = csv.split('\n');

expect(lines[0]).toBe(ADMIN_AUDIT_CSV_COLUMNS.join(','));
expect(lines[1]).toBe(
'log-1,actor-1,payment_void,Payment,payment-1,reason,203.0.113.7,jest-agent,2025-01-01T00:00:00.000Z',
);
});

it('neutralises a formula-prefixed detail in a full row', () => {
const csv = exportAdminAuditToCsv([
makeEntry({ detail: '=HYPERLINK("evil")' }),
]);

expect(csv.split('\n')[1]).toContain('"\'=HYPERLINK(""evil"")"');
});

it('returns just the header for an empty result set', () => {
expect(exportAdminAuditToCsv([])).toBe(ADMIN_AUDIT_CSV_COLUMNS.join(','));
});
});
Loading
Loading