Skip to content

Commit 36feb76

Browse files
Merge pull request #258 from RaymondAbiola/Automated_Sanctions_Screening
Automated Sanctions Screening via OFAC Integration
2 parents 27b9a71 + 5e5a6c9 commit 36feb76

9 files changed

Lines changed: 1760 additions & 6 deletions

middleware/sanctionsBlock.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* sanctionsBlock middleware
3+
*
4+
* Rejects every authenticated request whose underlying wallet is BLOCKED by
5+
* the sanctions screening pipeline. Designed to be the cheapest possible
6+
* gate — relies on the SanctionsScreeningService's in-process LRU cache so
7+
* a typical hit is a single Map lookup, not a DB query.
8+
*
9+
* Mount AFTER the auth middleware (so req.user is populated) on every route
10+
* we want structurally protected, e.g.
11+
*
12+
* app.use('/api', authenticateToken);
13+
* app.use('/api', createSanctionsBlockMiddleware({ service: sanctionsScreeningService }));
14+
*
15+
* Non-authenticated requests are passed through — they are screened at SEP-10
16+
* verify time before they ever get a token.
17+
*/
18+
function createSanctionsBlockMiddleware({ service, getWalletAddress } = {}) {
19+
if (!service) {
20+
throw new Error(
21+
'createSanctionsBlockMiddleware: service (SanctionsScreeningService) is required'
22+
);
23+
}
24+
const extract = getWalletAddress || defaultExtract;
25+
26+
return function sanctionsBlockMiddleware(req, res, next) {
27+
let walletAddress;
28+
try {
29+
walletAddress = extract(req);
30+
} catch (_e) {
31+
walletAddress = null;
32+
}
33+
34+
if (!walletAddress) {
35+
return next();
36+
}
37+
38+
let blocked;
39+
try {
40+
blocked = service.isBlocked(walletAddress);
41+
} catch (err) {
42+
// Don't fail-closed on a DB hiccup — log and continue. The screening
43+
// step at SEP-10 verify is the authoritative gate; this middleware is
44+
// a defence-in-depth net.
45+
// eslint-disable-next-line no-console
46+
console.warn(
47+
'[sanctionsBlock] isBlocked check failed:',
48+
err && err.message ? err.message : err
49+
);
50+
return next();
51+
}
52+
53+
if (blocked) {
54+
return res.status(403).json({
55+
success: false,
56+
error: 'ACCOUNT_BLOCKED',
57+
message:
58+
'This account is blocked due to sanctions screening. Contact the compliance officer to request a review.',
59+
});
60+
}
61+
62+
return next();
63+
};
64+
}
65+
66+
function defaultExtract(req) {
67+
if (!req || !req.user) return null;
68+
return req.user.walletAddress || req.user.publicKey || req.user.address || null;
69+
}
70+
71+
module.exports = {
72+
createSanctionsBlockMiddleware,
73+
};
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Sanctions Screening Tables
3+
*
4+
* Stores the result of every OFAC / global-sanctions check we run, plus the
5+
* status of every screened wallet (so requests from BLOCKED accounts can be
6+
* rejected at the middleware layer without re-querying the upstream provider).
7+
*
8+
* - screened_users : the per-wallet status row (the "User" status the
9+
* issue refers to). account_status is the gate
10+
* checked by the request-time middleware.
11+
* - security_audit : append-only audit log of every screening event,
12+
* with provider identity, risk score, and reason
13+
* for the flag. Satisfies regulatory reporting.
14+
* - sanctions_review_queue : compliance-officer worklist for false-positive
15+
* review. cleared addresses are unblocked and
16+
* re-screening is suppressed via override_until.
17+
*/
18+
exports.up = async function up(knex) {
19+
const hasScreenedUsers = await knex.schema.hasTable('screened_users');
20+
if (!hasScreenedUsers) {
21+
await knex.schema.createTable('screened_users', (table) => {
22+
table.string('wallet_address').primary();
23+
table.string('account_status').notNullable().defaultTo('ACTIVE');
24+
table.string('risk_level').nullable();
25+
table.decimal('risk_score', 10, 4).nullable();
26+
table.text('flagged_lists').nullable();
27+
table.text('block_reason').nullable();
28+
table.string('blocking_provider').nullable();
29+
table.timestamp('last_screened_at').nullable();
30+
table.string('last_audit_id').nullable();
31+
table.timestamp('blocked_at').nullable();
32+
table.timestamp('unblocked_at').nullable();
33+
table.string('unblocked_by').nullable();
34+
table.timestamp('override_until').nullable();
35+
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
36+
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
37+
38+
table.index(['account_status']);
39+
table.index(['risk_level']);
40+
table.index(['last_screened_at']);
41+
});
42+
}
43+
44+
const hasSecurityAudit = await knex.schema.hasTable('security_audit');
45+
if (!hasSecurityAudit) {
46+
await knex.schema.createTable('security_audit', (table) => {
47+
table.string('id').primary();
48+
table.string('wallet_address').notNullable().index();
49+
table.string('event_type').notNullable();
50+
table.string('provider').nullable();
51+
table.string('risk_level').nullable();
52+
table.decimal('risk_score', 10, 4).nullable();
53+
table.text('flagged_lists').nullable();
54+
table.text('reason').nullable();
55+
table.text('provider_response').nullable();
56+
table.string('triggering_action').nullable();
57+
table.string('actor').nullable();
58+
table.string('ip_address').nullable();
59+
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
60+
61+
table.index(['wallet_address', 'created_at']);
62+
table.index(['event_type', 'created_at']);
63+
table.index(['provider', 'created_at']);
64+
});
65+
}
66+
67+
const hasReviewQueue = await knex.schema.hasTable('sanctions_review_queue');
68+
if (!hasReviewQueue) {
69+
await knex.schema.createTable('sanctions_review_queue', (table) => {
70+
table.string('id').primary();
71+
table.string('wallet_address').notNullable();
72+
table.string('triggering_audit_id').nullable();
73+
table.string('status').notNullable().defaultTo('open');
74+
table.string('risk_level').nullable();
75+
table.decimal('risk_score', 10, 4).nullable();
76+
table.text('flagged_lists').nullable();
77+
table.text('reason').nullable();
78+
table.timestamp('submitted_at').notNullable().defaultTo(knex.fn.now());
79+
table.timestamp('reviewed_at').nullable();
80+
table.string('reviewed_by').nullable();
81+
table.text('decision_notes').nullable();
82+
83+
table.index(['wallet_address', 'status']);
84+
table.index(['status', 'submitted_at']);
85+
});
86+
87+
await knex.raw(
88+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_sanctions_review_queue_one_open_per_wallet
89+
ON sanctions_review_queue (wallet_address) WHERE status = 'open'`
90+
);
91+
}
92+
};
93+
94+
exports.down = async function down(knex) {
95+
await knex.schema.dropTableIfExists('sanctions_review_queue');
96+
await knex.schema.dropTableIfExists('security_audit');
97+
await knex.schema.dropTableIfExists('screened_users');
98+
};

routes/sanctionsCompliance.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const express = require('express');
2+
const { SanctionsScreeningService } = require('../src/services/sanctionsScreeningService');
3+
4+
/**
5+
* Compliance officer surface for reviewing sanctions hits.
6+
*
7+
* GET /api/compliance/sanctions/queue - list open review tickets
8+
* POST /api/compliance/sanctions/:address/false-positive
9+
* - clear an address (unblock)
10+
* POST /api/compliance/sanctions/:address/confirm
11+
* - confirm sanction (stay blocked)
12+
* GET /api/compliance/sanctions/:address/audit - full audit trail
13+
* GET /api/compliance/sanctions/:address - current account status
14+
*
15+
* The router is service-injectable so the main app and tests can share a
16+
* single SanctionsScreeningService (and its in-process cache).
17+
*/
18+
function createSanctionsComplianceRoutes(deps = {}) {
19+
const router = express.Router();
20+
21+
const getService = (req) => {
22+
if (deps.sanctionsScreeningService) return deps.sanctionsScreeningService;
23+
const fromApp = req.app.get('sanctionsScreeningService');
24+
if (fromApp) return fromApp;
25+
26+
const database = deps.database || req.app.get('database');
27+
if (!database) {
28+
throw new Error('SanctionsScreeningService unavailable: no database configured');
29+
}
30+
const service = new SanctionsScreeningService({ database });
31+
req.app.set('sanctionsScreeningService', service);
32+
return service;
33+
};
34+
35+
router.get('/queue', (req, res) => {
36+
try {
37+
const { status, limit } = req.query;
38+
const service = getService(req);
39+
const queue = service.listReviewQueue({ status, limit });
40+
return res.status(200).json({ success: true, data: queue });
41+
} catch (error) {
42+
return res
43+
.status(500)
44+
.json({ success: false, error: error.message || 'Queue lookup failed' });
45+
}
46+
});
47+
48+
router.post('/:address/false-positive', async (req, res) => {
49+
try {
50+
const { address } = req.params;
51+
const { reviewedBy, decisionNotes } = req.body || {};
52+
if (!reviewedBy) {
53+
return res
54+
.status(400)
55+
.json({ success: false, error: 'reviewedBy is required' });
56+
}
57+
const service = getService(req);
58+
const result = await service.markFalsePositive({
59+
walletAddress: address,
60+
reviewedBy,
61+
decisionNotes,
62+
});
63+
return res.status(200).json({ success: true, data: result });
64+
} catch (error) {
65+
const status = /not currently blocked/i.test(error.message) ? 409 : 500;
66+
return res
67+
.status(status)
68+
.json({ success: false, error: error.message || 'Review failed' });
69+
}
70+
});
71+
72+
router.post('/:address/confirm', async (req, res) => {
73+
try {
74+
const { address } = req.params;
75+
const { reviewedBy, decisionNotes } = req.body || {};
76+
if (!reviewedBy) {
77+
return res
78+
.status(400)
79+
.json({ success: false, error: 'reviewedBy is required' });
80+
}
81+
const service = getService(req);
82+
const result = await service.confirmSanction({
83+
walletAddress: address,
84+
reviewedBy,
85+
decisionNotes,
86+
});
87+
return res.status(200).json({ success: true, data: result });
88+
} catch (error) {
89+
const status = /not currently blocked/i.test(error.message) ? 409 : 500;
90+
return res
91+
.status(status)
92+
.json({ success: false, error: error.message || 'Review failed' });
93+
}
94+
});
95+
96+
router.get('/:address/audit', (req, res) => {
97+
try {
98+
const { address } = req.params;
99+
const { limit } = req.query;
100+
const service = getService(req);
101+
const audit = service.getAuditTrail(address, { limit });
102+
return res.status(200).json({ success: true, data: audit });
103+
} catch (error) {
104+
return res
105+
.status(500)
106+
.json({ success: false, error: error.message || 'Audit lookup failed' });
107+
}
108+
});
109+
110+
router.get('/:address', (req, res) => {
111+
try {
112+
const { address } = req.params;
113+
const service = getService(req);
114+
const status = service.getAccountStatus(address);
115+
if (!status) {
116+
return res.status(404).json({
117+
success: false,
118+
error: `No screening record for ${address}`,
119+
});
120+
}
121+
return res.status(200).json({ success: true, data: status });
122+
} catch (error) {
123+
return res
124+
.status(500)
125+
.json({ success: false, error: error.message || 'Status lookup failed' });
126+
}
127+
});
128+
129+
return router;
130+
}
131+
132+
module.exports = createSanctionsComplianceRoutes;

routes/stellarAuth.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,42 @@ router.post('/verify', async (req, res) => {
7373

7474
// Verify the challenge
7575
const verification = await stellarService.verifyChallenge(challengeXDR, publicKey);
76-
76+
7777
if (!verification.success) {
7878
return res.status(400).json({
7979
success: false,
8080
error: verification.error
8181
});
8282
}
8383

84+
// OFAC / global-sanctions screening — must complete BEFORE we issue a
85+
// JWT, otherwise a flagged wallet would have a valid token in the wild.
86+
const sanctionsService = req.app.get('sanctionsScreeningService');
87+
if (sanctionsService) {
88+
try {
89+
const screen = await sanctionsService.screenAddress(publicKey, {
90+
triggeringAction: 'sep10_verify',
91+
ipAddress: req.ip,
92+
});
93+
if (!screen.allowed) {
94+
return res.status(403).json({
95+
success: false,
96+
error: 'ACCOUNT_BLOCKED',
97+
message:
98+
'This wallet is blocked by sanctions screening. Contact compliance for review.',
99+
auditId: screen.auditId,
100+
});
101+
}
102+
} catch (screenError) {
103+
console.error('Sanctions screening error during SEP-10 verify:', screenError);
104+
// Treat unexpected service errors as fail-closed at the gate.
105+
return res.status(503).json({
106+
success: false,
107+
error: 'Sanctions screening unavailable; please retry shortly.',
108+
});
109+
}
110+
}
111+
84112
// Determine user tier (in production, fetch from database)
85113
// For now, assign bronze tier to all users
86114
const userTier = 'bronze';

routes/subscription.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,42 @@ router.post('/events', async (req, res) => {
2424
return res.status(400).json({ success: false, error: 'Missing required fields: type, creatorId' });
2525
}
2626

27+
// OFAC / global-sanctions screening for subscription initialization. We
28+
// only screen on 'subscribed' (the act of initializing a subscription).
29+
// Already-blocked wallets are rejected with zero latency via the cached
30+
// isBlocked() check; new wallets get a full provider call.
31+
if (walletAddress && String(type).toLowerCase() === 'subscribed') {
32+
const sanctionsService = app.get('sanctionsScreeningService');
33+
if (sanctionsService) {
34+
try {
35+
const screen = await sanctionsService.screenAddress(walletAddress, {
36+
triggeringAction: 'subscription_init',
37+
ipAddress: ipAddress || req.ip,
38+
actor: creatorId,
39+
});
40+
if (!screen.allowed) {
41+
return res.status(403).json({
42+
success: false,
43+
error: 'ACCOUNT_BLOCKED',
44+
message:
45+
'Subscription rejected: wallet is blocked by sanctions screening.',
46+
auditId: screen.auditId,
47+
});
48+
}
49+
} catch (screenError) {
50+
// eslint-disable-next-line no-console
51+
console.error(
52+
'Sanctions screening error during subscription init:',
53+
screenError && screenError.stack ? screenError.stack : screenError
54+
);
55+
return res.status(503).json({
56+
success: false,
57+
error: 'Sanctions screening unavailable; please retry shortly.',
58+
});
59+
}
60+
}
61+
}
62+
2763
const result = await subscriptionService.handleEvent({ type, creatorId, walletAddress, timestamp, ipAddress });
2864

2965
return res.status(200).json({ success: true, data: result });

0 commit comments

Comments
 (0)