Skip to content
Open
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
125 changes: 125 additions & 0 deletions docs/compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,128 @@ Before production deployment:
**Last Updated**: May 28, 2026
**Maintained By**: LiquiFact Backend Team
**Related Issues**: #222 — Enforce KYC gating on all capital-movement endpoints

---

## Invoice Audit Trail & State-Transition History API

**Status**: Implemented
**Date**: May 2026
**Relates to**: Issue #208 — Add admin endpoint for invoice audit trail and state-transition history export

### Overview

Compliance operators can retrieve and export the full audit history for any invoice, including all mutations and state transitions. All endpoints are admin-gated and tenant-isolated.

### Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/audit/invoices/:invoiceId` | Paginated audit trail |
| GET | `/api/admin/audit/invoices/:invoiceId/transitions` | State-transition history |
| GET | `/api/admin/audit/invoices/:invoiceId/export` | Export as JSON or CSV |

### Authentication

All endpoints accept either:
- `Authorization: Bearer <JWT>` — admin JWT with `tenantId` claim
- `X-API-KEY: <key>` — service-to-service API key

Tenant context is resolved from the `x-tenant-id` header (highest priority) or the `tenantId` JWT claim. Requests without a resolvable tenant are rejected with `400`.

### Tenant Isolation

Every query is scoped to the authenticated operator's tenant. An operator cannot retrieve audit records belonging to another tenant.

### Pagination

Query params: `limit` (1–500, default 50) and `offset` (default 0).

```bash
GET /api/admin/audit/invoices/inv-001?limit=20&offset=40
```

Response includes a `meta` object:
```json
{
"data": [...],
"meta": { "invoiceId": "inv-001", "limit": 20, "offset": 40, "total": 87 }
}
```

### Export Formats

#### JSON (default)

```bash
curl -H "Authorization: Bearer $TOKEN" \
-H "x-tenant-id: tenant-alpha" \
"http://localhost:3001/api/admin/audit/invoices/inv-001/export"
```

Returns `application/json` — a JSON array of audit log entries.

#### CSV

```bash
curl -H "Authorization: Bearer $TOKEN" \
-H "x-tenant-id: tenant-alpha" \
"http://localhost:3001/api/admin/audit/invoices/inv-001/export?format=csv" \
-o audit-inv-001.csv
```

Returns `text/csv` with `Content-Disposition: attachment`. CSV columns:

```
id,timestamp,actor,action,resourceType,resourceId,statusCode,ipAddress,userAgent
```

Fields containing commas, double-quotes, or newlines are RFC 4180-escaped (wrapped in double-quotes, internal quotes doubled).

### Secret Redaction

Sensitive fields (`password`, `token`, `secret`, `apiKey`, `privateKey`, etc.) are redacted to `***REDACTED***` before any log entry is stored or exported. This is enforced at write time by `sanitizeSensitiveData` in `src/services/auditLog.js` and `redactValue` in `src/services/auditLogStore.js`.

### State-Transition History

```bash
curl -H "Authorization: Bearer $TOKEN" \
-H "x-tenant-id: tenant-alpha" \
"http://localhost:3001/api/admin/audit/invoices/inv-001/transitions"
```

Response:
```json
{
"data": [
{
"id": "AUDIT-...",
"timestamp": "2026-05-30T10:00:00.000Z",
"actor": "admin-1",
"fromState": "pending",
"toState": "approved",
"reason": null,
"ipAddress": "127.0.0.1"
}
],
"meta": { "invoiceId": "inv-001" }
}
```

### Security Notes

- Endpoints are read-only; no mutations are possible through this API.
- Input validation rejects `invoiceId` values longer than 128 characters.
- Pagination bounds are clamped server-side (max 500 per page).
- All responses omit internal stack traces and infrastructure details.
- The audit log store is append-only at the database layer (see `migrations/202604260002_enforce_audit_log_append_only.sql`).

### Deployment Checklist

- [ ] Ensure `JWT_SECRET` is set in deployment secrets
- [ ] Confirm `x-tenant-id` header is forwarded by API gateway / load balancer
- [ ] Verify audit log DB migrations have run (`npm run db:migrate`)
- [ ] Run tests: `npx jest tests/auditTrail.api.test.js`

**Last Updated**: May 30, 2026
**Relates to**: Issue #208
2 changes: 2 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const logger = require('./logger');
const { metricsAuth, metricsHandler } = require('./metrics');
const smeRoutes = require('./routes/sme');
const invoiceFileRoutes = require('./routes/invoiceFile');
const auditTrailRoutes = require('./routes/auditTrail');

/**
* Returns a 403 JSON response only for the dedicated blocked-origin CORS error.
Expand Down Expand Up @@ -248,6 +249,7 @@ function createApp() {
// ── 5. SME & Invoice File routes ─────────────────────────────────────────
app.use('/api/sme', smeRoutes);
app.use('/api/invoices', invoiceFileRoutes);
app.use('/api/admin/audit', auditTrailRoutes);

// ── 6. Prometheus metrics ────────────────────────────────────────────────
app.get('/metrics', metricsAuth, metricsHandler);
Expand Down
153 changes: 153 additions & 0 deletions src/routes/auditTrail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use strict';

/**
* @fileoverview Admin routes for invoice audit trail and state-transition history export.
* All routes require admin authentication (JWT or API key) and tenant isolation.
*
* Routes:
* GET /api/admin/audit/invoices/:invoiceId - Paginated audit trail
* GET /api/admin/audit/invoices/:invoiceId/transitions - State-transition history
* GET /api/admin/audit/invoices/:invoiceId/export - Export as JSON or CSV
*
* @module routes/auditTrail
*/

const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { apiKeyAuth } = require('../middleware/apiKey');
const { extractTenant } = require('../middleware/tenant');
const { getInvoiceAuditTrail, countAuditLogs, exportInvoiceAuditLogs, getAuditLogs } = require('../services/auditLog');
const { getTransitionHistory } = require('../services/invoiceStateMachine');
const AppError = require('../errors/AppError');

const MAX_LIMIT = 500;
const DEFAULT_LIMIT = 50;

/**
* Accepts either a valid admin JWT or a valid API key.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
function adminAuth(req, res, next) {
if (req.headers['x-api-key']) {
return apiKeyAuth(req, res, next);
}
return authenticateToken(req, res, next);
}

/**
* Parse and clamp pagination params from query string.
* @param {object} query
* @returns {{ limit: number, offset: number }}
*/
function parsePagination(query) {
const limit = Math.min(Math.max(parseInt(query.limit, 10) || DEFAULT_LIMIT, 1), MAX_LIMIT);
const offset = Math.max(parseInt(query.offset, 10) || 0, 0);
return { limit, offset };
}

/**
* Validate invoiceId path param — reject obviously malformed values.
* @param {string} invoiceId
* @returns {boolean}
*/
function isValidInvoiceId(invoiceId) {
return typeof invoiceId === 'string' && invoiceId.length > 0 && invoiceId.length <= 128;
}

// ── Middleware stack for all routes ──────────────────────────────────────────
router.use(adminAuth, extractTenant);

/**
* GET /api/admin/audit/invoices/:invoiceId
* Returns paginated audit trail for a specific invoice.
* Tenant-scoped: only returns records matching req.tenantId.
*/
router.get('/invoices/:invoiceId', (req, res, next) => {
try {
const { invoiceId } = req.params;
if (!isValidInvoiceId(invoiceId)) {
return next(new AppError({
type: 'https://liquifact.com/probs/validation-error',
title: 'Validation Error',
status: 400,
detail: 'Invalid invoiceId.',
}));
}

const { limit, offset } = parsePagination(req.query);
const logs = getInvoiceAuditTrail(invoiceId, limit, offset, req.tenantId);
const total = countAuditLogs({ resourceId: invoiceId, resourceType: 'invoice', tenantId: req.tenantId });

return res.json({
data: logs,
meta: { invoiceId, limit, offset, total },
});
} catch (err) {
return next(err);
}
});

/**
* GET /api/admin/audit/invoices/:invoiceId/transitions
* Returns state-transition history for a specific invoice.
*/
router.get('/invoices/:invoiceId/transitions', (req, res, next) => {
try {
const { invoiceId } = req.params;
if (!isValidInvoiceId(invoiceId)) {
return next(new AppError({
type: 'https://liquifact.com/probs/validation-error',
title: 'Validation Error',
status: 400,
detail: 'Invalid invoiceId.',
}));
}

const transitions = getTransitionHistory(invoiceId, (opts) =>
getAuditLogs({ ...opts, tenantId: req.tenantId })
);

return res.json({ data: transitions, meta: { invoiceId } });
} catch (err) {
return next(err);
}
});

/**
* GET /api/admin/audit/invoices/:invoiceId/export
* Exports audit trail as JSON or CSV.
* Query params: format=json|csv, limit, offset
*/
router.get('/invoices/:invoiceId/export', (req, res, next) => {
try {
const { invoiceId } = req.params;
if (!isValidInvoiceId(invoiceId)) {
return next(new AppError({
type: 'https://liquifact.com/probs/validation-error',
title: 'Validation Error',
status: 400,
detail: 'Invalid invoiceId.',
}));
}

const format = req.query.format === 'csv' ? 'csv' : 'json';
const { limit } = parsePagination(req.query);
const output = exportInvoiceAuditLogs({ invoiceId, limit, format, tenantId: req.tenantId });

if (format === 'csv') {
res.set('Content-Type', 'text/csv');
res.set('Content-Disposition', `attachment; filename="audit-${invoiceId}.csv"`);
return res.send(output);
}

res.set('Content-Type', 'application/json');
return res.send(output);
} catch (err) {
return next(err);
}
});

module.exports = router;
Loading