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
94 changes: 71 additions & 23 deletions docs/compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

## Overview

This document outlines the KYC (Know Your Customer) compliance framework implemented in the LiquiFact backend. The system enforces SME identity verification before allowing capital deployment through funding endpoints.
This document outlines the KYC (Know Your Customer) compliance framework implemented in the LiquiFact backend. The system enforces SME identity verification before allowing capital deployment through **all** funding and settlement endpoints.

**Status**: Production-ready implementation with optional external provider integration.
**Date**: April 2026
**Version**: 1.0.0
**Date**: May 2026
**Version**: 1.1.0
**Relates to**: Issue #222 — Enforce KYC gating on all capital-movement endpoints

---

Expand Down Expand Up @@ -83,26 +84,46 @@ kycService.canFundWithKycStatus(status) → boolean

**File**: `src/middleware/kycGating.js`

The `requireKycForFunding` middleware enforces KYC requirements on sensitive endpoints:
The `requireKycForFunding` middleware enforces KYC requirements on **all** capital-movement endpoints.

#### Security contract — smeId resolution (anti-spoofing fix, issue #222)

Prior to this fix, `smeId` was resolved as
`req.user.smeId || req.body.smeId || req.params.smeId`, which allowed an
authenticated caller to supply a verified SME's ID in the request body or URL
parameter and pass the gate for an SME they do not own.

**The gate now resolves `smeId` exclusively from `req.user.smeId`** — the JWT
claim set by `authenticateToken`. Body and parameter values are intentionally
ignored during the identity check.

```javascript
app.post('/api/invest/fund-invoice',
authenticateToken,
requireKycForFunding, // ⭐ KYC gate
fundingHandler
);
// ✅ CORRECT — smeId tied to authenticated principal
const smeId = req.user.smeId || null;

// ❌ OLD (vulnerable) — body/params could be spoofed
// const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId;
```

#### Gated endpoints

| Endpoint | Method | Gate |
|---|---|---|
| `/api/invest/fund-invoice` | POST | `requireKycForFunding` |
| `/api/invoices/:id/link-escrow` | POST | `requireKycForFunding` |
| `/api/invoices/:id/transition` | POST | `conditionalKycGate` (only when `targetState` ∈ `{funded, settled}`) |

**Behavior**:
1. Validates user is authenticated
2. Extracts SME ID from request (via JWT claim, body, or params)
3. Checks KYC status for that SME
4. Returns 403 if status is not 'verified' or 'exempted'
5. Attaches KYC metadata to `req.kyc` for downstream handlers
2. Extracts `smeId` exclusively from the JWT (`req.user.smeId`)
3. Returns `400 MISSING_SME_ID` if the JWT contains no `smeId` claim
4. Checks KYC status for the authenticated SME
5. Returns `403 KYC_GATE_FAILED` if status is not `'verified'` or `'exempted'`
6. Attaches `{ smeId, status, recordId, verifiedAt }` to `req.kyc` for downstream handlers

**Error Codes**:
- `401 UNAUTHORIZED`: No authentication
- `400 MISSING_SME_ID`: SME ID not provided
- `400 MISSING_SME_ID`: JWT contains no `smeId` claim
- `403 KYC_GATE_FAILED`: KYC verification not met
- `500 KYC_CHECK_FAILED`: Service error during KYC lookup

Expand All @@ -114,7 +135,7 @@ app.post('/api/invest/fund-invoice',

#### POST /api/invest/fund-invoice

Initiates capital transfer to escrow. **Requires KYC verification**.
Initiates capital transfer to escrow. **Requires KYC verification** (`smeId` from JWT).

**Request**:
```json
Expand Down Expand Up @@ -235,6 +256,22 @@ curl -X POST http://localhost:3001/api/invest/fund-invoice \

---

#### POST /api/invoices/:id/link-escrow *(added — issue #222)*

Links an approved invoice into the escrow funding lifecycle. **Requires KYC verification**.

The `smeId` is resolved from `req.user.smeId` (JWT). If absent, returns `400 MISSING_SME_ID`.

---

#### POST /api/invoices/:id/transition *(conditionally gated — issue #222)*

Executes an invoice state transition. KYC is required only when the `targetState` is a
capital-moving state (`funded` or `settled`). Non-capital transitions (`approved`, `rejected`)
are not blocked by this gate.

---

## Environment Configuration

### Optional KYC Provider Integration
Expand Down Expand Up @@ -303,8 +340,9 @@ if (!validation.valid) {
3. **Tenant Isolation**: Each request includes tenant context (via header or JWT)
4. **Rate Limiting**: KYC endpoints subject to sensitive rate limits (40 req/hour)

**Middleware Stack**:
**Middleware Stack** (capital-movement endpoints):
```javascript
// Example: POST /api/invest/fund-invoice
app.post('/api/invest/fund-invoice',
requestIdMiddleware, // Add request ID
pinoHttpLogger, // Log request
Expand All @@ -315,13 +353,18 @@ app.post('/api/invest/fund-invoice',
sentryRequestHandler, // Error tracking
rateLimiter, // 40 req/hour for sensitive ops
auditMiddleware, // Log mutation
authenticateToken, // ⭐ Verify JWT
tenantMiddleware, // ⭐ Extract tenant
requireKycForFunding, // ⭐ KYC gate
authenticateToken, // ⭐ Verify JWT (sets req.user)
tenantMiddleware, // ⭐ Extract tenant (sets req.tenantId)
requireKycForFunding, // ⭐ KYC gate (smeId from JWT only)
fundingHandler // Business logic
);
```

> **Security note**: `smeId` for KYC lookup is resolved exclusively from
> `req.user.smeId` (the verified JWT claim). Callers cannot supply a spoofed
> `smeId` via `req.body` or `req.params` to pass the gate for an SME they do
> not own.

### Key Handling

**For external KYC provider integration**:
Expand Down Expand Up @@ -481,11 +524,15 @@ curl -X POST http://localhost:3001/api/invest/fund-invoice \

## Roadmap & Future Work

### Phase 1: Current (✅ Complete)
### Phase 1: Complete ✅
- ✅ Invoice schema with kycStatus field
- ✅ KYC service with mock implementation
- ✅ KYC gating middleware
- ✅ Funding endpoint protection
- ✅ Funding endpoint protection (`POST /api/invest/fund-invoice`)
- ✅ **KYC gate on ALL capital-movement endpoints** (issue #222)
- ✅ `POST /api/invoices/:id/link-escrow`
- ✅ `POST /api/invoices/:id/transition` (capital-moving states)
- ✅ **Anti-spoofing: smeId resolved from JWT only** (issue #222)
- ✅ Comprehensive testing (95%+ coverage)
- ✅ Documentation

Expand Down Expand Up @@ -570,5 +617,6 @@ Before production deployment:

---

**Last Updated**: April 25, 2026
**Maintained By**: LiquiFact Backend Team
**Last Updated**: May 28, 2026
**Maintained By**: LiquiFact Backend Team
**Related Issues**: #222 — Enforce KYC gating on all capital-movement endpoints
77 changes: 49 additions & 28 deletions src/middleware/kycGating.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* KYC Gating Middleware
* Enforces KYC requirements before allowing access to sensitive endpoints
*
*
* @module middleware/kycGating
*/

Expand All @@ -10,26 +10,33 @@ const kycService = require('../services/kycService');
const logger = require('../logger');

/**
* Middleware to enforce KYC verification for funding operations
*
* Should be applied to:
* Middleware to enforce KYC verification for all capital-movement operations.
*
* Apply to every route that initiates or settles escrow funding:
* - POST /api/invest/fund-invoice
* - POST /api/invoices/:id/fund
* - Any endpoint that initiates capital transfer
*
* Requirements:
* - User must be authenticated (req.user must exist)
* - SME must have KYC status of 'verified' or 'exempted'
* - Tenant isolation is enforced (via tenant middleware)
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware
* @throws {AppError} 403 if KYC requirements not met
* - POST /api/invoices/:id/link-escrow
* - POST /api/invoices/:id/transition (when targetState is a capital-moving state)
* - Any future endpoint that transfers or releases capital
*
* Security contract:
* - User MUST be authenticated (`req.user` populated by `authenticateToken`).
* - `smeId` is resolved ONLY from the authenticated JWT principal (`req.user.smeId`).
* Callers CANNOT override this via `req.body.smeId` or `req.params.smeId` — doing
* so would allow an attacker to supply a verified SME's ID they do not own.
* - SME must hold KYC status of 'verified' or 'exempted'.
* - Tenant isolation is enforced upstream (via `extractTenant` middleware).
*
* @param {import('express').Request} req - Express request object
* @param {import('express').Response} res - Express response object
* @param {import('express').NextFunction} next - Express next middleware
* @returns {Promise<void>}
* @throws {AppError} 401 if unauthenticated
* @throws {AppError} 400 if the JWT contains no smeId claim
* @throws {AppError} 403 if KYC requirements are not met
*/
async function requireKycForFunding(req, res, next) {
try {
// 1. Validate authentication
// 1. Validate authentication — authenticateToken must have run first.
if (!req.user || !req.user.sub) {
const error = new AppError({
type: 'https://liquifact.com/probs/unauthorized',
Expand All @@ -42,23 +49,31 @@ async function requireKycForFunding(req, res, next) {
return next(error);
}

// 2. Extract SME ID from request
// Can come from: req.user.smeId, req.body.smeId, or req.params.smeId
const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId;
// 2. Resolve smeId STRICTLY from the authenticated JWT principal.
//
// SECURITY NOTE: We intentionally do NOT fall back to req.body.smeId or
// req.params.smeId. If we did, any authenticated user could supply a
// verified SME's ID in the request body/params and pass the KYC gate for
// an SME they do not own. The smeId MUST come from the token that was
// issued to this specific principal.
//
// Convention: tokens may carry the SME identity as either `smeId` or,
// for tokens where the subject *is* the SME, as `sub`.
const smeId = req.user.smeId || null;

if (!smeId) {
const error = new AppError({
type: 'https://liquifact.com/probs/validation-error',
title: 'Validation Error',
status: 400,
detail: 'SME ID is required for funding operations.',
detail: 'SME ID is required for funding operations. Ensure your JWT contains a valid smeId claim.',
instance: req.originalUrl,
code: 'MISSING_SME_ID',
});
return next(error);
}

// 3. Check KYC status
// 3. Check KYC status for the authenticated principal's SME.
const kycRecord = await kycService.getKycStatus(smeId);
const canFund = kycService.canFundWithKycStatus(kycRecord.status);

Expand All @@ -73,7 +88,7 @@ async function requireKycForFunding(req, res, next) {
'KYC gate check'
);

// 4. Enforce gate
// 4. Enforce gate — block if KYC is not in an acceptable state.
if (!canFund) {
const error = new AppError({
type: 'https://liquifact.com/probs/kyc-required',
Expand All @@ -88,8 +103,9 @@ async function requireKycForFunding(req, res, next) {
return next(error);
}

// 5. Attach KYC info to request for downstream handlers
// 5. Attach verified KYC metadata to the request for downstream handlers.
req.kyc = {
smeId,
status: kycRecord.status,
recordId: kycRecord.recordId,
verifiedAt: kycRecord.verifiedAt,
Expand Down Expand Up @@ -121,16 +137,21 @@ async function requireKycForFunding(req, res, next) {
}

/**
* Optional: Middleware to log KYC checks for audit trails
* Attach to general routes to track KYC interactions
* Middleware to log KYC access for audit trails.
* Attach after `requireKycForFunding` on gated routes to record every
* successful capital-movement access.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {Promise<void>}
*/
async function auditKycAccess(req, res, next) {
// Extract KYC info if available
if (req.kyc) {
logger.debug(
{
userId: req.user?.sub,
smeId: req.user?.smeId,
smeId: req.kyc.smeId,
kycStatus: req.kyc.status,
endpoint: req.path,
method: req.method,
Expand Down
47 changes: 42 additions & 5 deletions src/routes/invoiceStateRoutes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/**
* Invoice State Transition Routes
* Handles invoice lifecycle state transitions with audit logging
*
* Handles invoice lifecycle state transitions with audit logging.
*
* Capital-movement routes are protected by the KYC gate:
* - POST /:id/link-escrow — initiates escrow funding lifecycle
* - POST /:id/transition — when targetState is 'funded' or 'settled'
*
* @module routes/invoiceStateRoutes
*/

Expand All @@ -15,6 +19,18 @@ const {
canLinkToEscrow,
} = require('../services/invoiceStateMachine');
const { getAuditLogs } = require('../services/auditLog');
const { requireKycForFunding } = require('../middleware/kycGating');

/**
* States that initiate or settle capital movement and therefore require
* the caller to be KYC-verified before transitioning to them.
*/
const CAPITAL_MOVING_STATES = new Set([
'funded',
'settled',
INVOICE_STATES.FUNDED,
INVOICE_STATES.SETTLED,
].filter(Boolean));

/**
* Helper to extract actor from request
Expand Down Expand Up @@ -85,7 +101,25 @@ router.get('/:id/state', (req, res) => {
* "reason": "Invoice verified and approved by finance team"
* }
*/
router.post('/:id/transition', (req, res, next) => {
/**
* KYC gate selector for transition endpoint.
* Runs `requireKycForFunding` only when the requested targetState is a
* capital-moving state; passes through for non-capital transitions.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
function conditionalKycGate(req, res, next) {
const { targetState } = req.body || {};
if (targetState && CAPITAL_MOVING_STATES.has(targetState)) {
return requireKycForFunding(req, res, next);
}
return next();
}

router.post('/:id/transition', conditionalKycGate, (req, res, next) => {
const { id } = req.params;
const { targetState, reason } = req.body;

Expand Down Expand Up @@ -227,9 +261,12 @@ router.post('/:id/approve', (req, res, next) => {

/**
* POST /api/invoices/:id/link-escrow
* Link an approved invoice to escrow
* Link an approved invoice to escrow.
*
* This is a capital-movement endpoint: it initiates the escrow funding
* lifecycle. KYC must be verified before the link can be made.
*/
router.post('/:id/link-escrow', (req, res, next) => {
router.post('/:id/link-escrow', requireKycForFunding, (req, res, next) => {
const { id } = req.params;
const { escrowId, reason } = req.body;

Expand Down
Loading
Loading