feat: add member portal and auth hardening

This commit is contained in:
defiQUG
2026-04-18 12:05:17 -07:00
parent c80b2a543a
commit 468bc05b78
59 changed files with 4066 additions and 604 deletions

View File

@@ -2,40 +2,65 @@
import request from 'supertest';
import app from '@/integration/api-gateway/app';
import { createAuthHeaders, createTestToken } from '@/__tests__/utils/test-auth';
import { createTestToken } from '@/__tests__/utils/test-auth';
describe('Authentication Middleware', () => {
describe('zeroTrustAuthMiddleware', () => {
it('should reject requests without token', async () => {
const response = await request(app).get('/api/health').expect(401);
it('rejects requests without a token', async () => {
const response = await request(app).get('/api/auth/me').expect(401);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('UNAUTHORIZED');
});
it('should reject requests with invalid token', async () => {
it('rejects requests with an invalid token', async () => {
const response = await request(app)
.get('/api/health')
.get('/api/auth/me')
.set('authorization', 'SOV-TOKEN invalid-token')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('UNAUTHORIZED');
});
it('should accept requests with valid token', async () => {
// Note: This test may need adjustment based on actual route implementation
// Health endpoint should not require auth
const response = await request(app).get('/health').expect(200);
it('accepts a valid portal token without sovereign signature headers', async () => {
const token = createTestToken({
employeeId: 'EMP-001',
email: 'core.ops@d-bis.org',
name: 'Core Operations User',
roleName: 'DBIS_Core_User',
permissions: ['portal:access', 'admin:view'],
identityType: 'WEB_PORTAL',
sessionType: 'portal',
portalSurface: 'core',
sovereignBankId: undefined,
});
expect(response.body.status).toBe('healthy');
const response = await request(app)
.get('/api/auth/me')
.set('authorization', `SOV-TOKEN ${token}`)
.expect(200);
expect(response.body.user).toMatchObject({
employeeId: 'EMP-001',
email: 'core.ops@d-bis.org',
name: 'Core Operations User',
role: 'DBIS_Core_User',
permissions: ['portal:access', 'admin:view'],
});
});
});
describe('Request Signature Verification', () => {
it('should require signature headers', async () => {
const token = createTestToken({ sovereignBankId: 'test-bank' });
it('requires signature headers for service tokens', async () => {
const token = createTestToken({
sovereignBankId: 'test-bank',
identityType: 'API',
sessionType: 'service',
});
const response = await request(app)
.get('/api/health')
.get('/api/auth/me')
.set('authorization', `SOV-TOKEN ${token}`)
.expect(401);
@@ -43,4 +68,3 @@ describe('Authentication Middleware', () => {
});
});
});

View File

@@ -0,0 +1,102 @@
import { LeiValidationService } from '@/core/nostro-vostro/lei-validation.service';
import { DbisError } from '@/shared/types';
describe('LeiValidationService', () => {
const validLei = '5493001KJTIIGC8Y1R35';
const originalFetch = global.fetch;
const originalRequireRegistryValidation = process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION;
const originalAllowFormatOnlyFallback = process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK;
beforeEach(() => {
process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION = 'true';
process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK = 'false';
});
afterEach(() => {
global.fetch = originalFetch;
process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION = originalRequireRegistryValidation;
process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK = originalAllowFormatOnlyFallback;
jest.restoreAllMocks();
});
it('rejects malformed LEIs before registry lookup', async () => {
const service = new LeiValidationService();
await expect(
service.validateRegistrationCandidate({
lei: 'INVALID-LEI',
institutionName: 'Central Bank of Example',
country: 'US',
})
).rejects.toBeInstanceOf(DbisError);
});
it('accepts issued active LEIs whose legal name and country match the request', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
id: validLei,
attributes: {
entity: {
legalName: { name: 'Central Bank of Example' },
legalAddress: { country: 'US' },
status: 'ACTIVE',
},
registration: {
status: 'ISSUED',
initialRegistrationDate: '2020-01-01T00:00:00Z',
nextRenewalDate: '2027-01-01T00:00:00Z',
managingLou: 'TEST-LOU',
},
},
},
}),
}) as typeof fetch;
const service = new LeiValidationService();
const result = await service.validateRegistrationCandidate({
lei: validLei,
institutionName: 'Central Bank of Example',
country: 'US',
});
expect(result.registryValidated).toBe(true);
expect(result.legalName).toBe('Central Bank of Example');
expect(result.legalCountry).toBe('US');
expect(result.source).toBe('gleif');
});
it('rejects registration when the LEI legal name does not match', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
id: validLei,
attributes: {
entity: {
legalName: { name: 'Different Legal Name Ltd' },
legalAddress: { country: 'US' },
status: 'ACTIVE',
},
registration: {
status: 'ISSUED',
},
},
},
}),
}) as typeof fetch;
const service = new LeiValidationService();
await expect(
service.validateRegistrationCandidate({
lei: validLei,
institutionName: 'Central Bank of Example',
country: 'US',
})
).rejects.toBeInstanceOf(DbisError);
});
});

4
src/bootstrap/env.ts Normal file
View File

@@ -0,0 +1,4 @@
import dotenv from 'dotenv';
// Load process environment before any module performs validation or startup side effects.
dotenv.config();

View File

@@ -78,6 +78,17 @@ export interface GlobalOverviewDashboard {
}
export class GlobalOverviewService {
private readonly allowPlaceholderMetrics = process.env.DBIS_ENABLE_PLACEHOLDER_METRICS === 'true';
private isMissingTableError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
);
}
/**
* Get global overview dashboard
*/
@@ -133,41 +144,25 @@ export class GlobalOverviewService {
subsystems.push({ subsystem: 'GAS', status: 'down' });
}
// QPS (Quantum Payment System) - placeholder
subsystems.push({
subsystem: 'QPS',
status: 'healthy',
});
// Ω-Layer (Omega Layer) - placeholder
subsystems.push({
subsystem: 'Ω-Layer',
status: 'healthy',
});
// GPN (Global Payment Network) - placeholder
subsystems.push({
subsystem: 'GPN',
status: 'healthy',
});
// GRU Engine - placeholder
subsystems.push({
subsystem: 'GRU Engine',
status: 'healthy',
});
// Metaverse MEN - placeholder
subsystems.push({
subsystem: 'Metaverse MEN',
status: 'healthy',
});
// 6G Edge Grid - placeholder
subsystems.push({
subsystem: '6G Edge Grid',
status: 'healthy',
});
if (this.allowPlaceholderMetrics) {
subsystems.push(
{ subsystem: 'QPS', status: 'healthy' },
{ subsystem: 'Ω-Layer', status: 'healthy' },
{ subsystem: 'GPN', status: 'healthy' },
{ subsystem: 'GRU Engine', status: 'healthy' },
{ subsystem: 'Metaverse MEN', status: 'healthy' },
{ subsystem: '6G Edge Grid', status: 'healthy' }
);
} else {
subsystems.push(
{ subsystem: 'QPS', status: 'degraded' },
{ subsystem: 'Ω-Layer', status: 'degraded' },
{ subsystem: 'GPN', status: 'degraded' },
{ subsystem: 'GRU Engine', status: 'degraded' },
{ subsystem: 'Metaverse MEN', status: 'degraded' },
{ subsystem: '6G Edge Grid', status: 'degraded' }
);
}
return subsystems;
}
@@ -176,188 +171,237 @@ export class GlobalOverviewService {
* Get settlement throughput metrics
*/
async getSettlementThroughput(): Promise<SettlementThroughput> {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
try {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
// Get all settlements in last 24 hours
const settlements = await prisma.atomic_settlements.findMany({
where: {
createdAt: {
gte: oneDayAgo,
where: {
createdAt: {
gte: oneDayAgo,
},
},
},
});
});
// Get settlements in last minute for tx/sec
const recentSettlements = settlements.filter(
(s: any) => s.createdAt >= oneMinuteAgo
);
const recentSettlements = settlements.filter(
(s: any) => s.createdAt >= oneMinuteAgo
);
const txPerSecond = recentSettlements.length / 60;
// Calculate daily volume
const dailyVolume = settlements
const txPerSecond = recentSettlements.length / 60;
const dailyVolume = settlements
.filter((s: any) => s.status === 'settled')
.reduce((sum: Decimal, s: { amount?: unknown }) => sum.plus(Number(s.amount ?? 0)), new Decimal(0))
.toNumber();
.toNumber();
// Group by asset type
const byAssetType = {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
};
const byAssetType = {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
};
settlements.forEach((s: any) => {
if (s.assetType === 'currency') byAssetType.fiat += parseFloat(s.amount.toString());
else if (s.assetType === 'cbdc') byAssetType.cbdc += parseFloat(s.amount.toString());
else if (s.assetType === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString());
// GRU and SSU would need additional queries
});
settlements.forEach((s: any) => {
if (s.assetType === 'currency') byAssetType.fiat += parseFloat(s.amount.toString());
else if (s.assetType === 'cbdc') byAssetType.cbdc += parseFloat(s.amount.toString());
else if (s.assetType === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString());
});
// Heatmap: top corridors by volume
const corridorMap = new Map<string, number>();
settlements.forEach((s: any) => {
if (s.status === 'settled') {
const key = `${s.sourceBankId}-${s.destinationBankId}`;
const current = corridorMap.get(key) || 0;
corridorMap.set(key, current + parseFloat(s.amount.toString()));
}
});
const corridorMap = new Map<string, number>();
settlements.forEach((s: any) => {
if (s.status === 'settled') {
const key = `${s.sourceBankId}-${s.destinationBankId}`;
const current = corridorMap.get(key) || 0;
corridorMap.set(key, current + parseFloat(s.amount.toString()));
}
});
const heatmap = Array.from(corridorMap.entries())
.map(([key, volume]) => {
const [source, dest] = key.split('-');
return { sourceSCB: source, destinationSCB: dest, volume };
})
.sort((a, b) => b.volume - a.volume)
.slice(0, 20); // Top 20
const heatmap = Array.from(corridorMap.entries())
.map(([key, volume]) => {
const [source, dest] = key.split('-');
return { sourceSCB: source, destinationSCB: dest, volume };
})
.sort((a, b) => b.volume - a.volume)
.slice(0, 20);
return {
txPerSecond,
dailyVolume,
byAssetType,
heatmap,
};
return {
txPerSecond,
dailyVolume,
byAssetType,
heatmap,
};
} catch (error) {
logger.warn('Settlement throughput metrics unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
txPerSecond: 0,
dailyVolume: 0,
byAssetType: {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
},
heatmap: [],
};
}
}
/**
* Get GRU & liquidity metrics
*/
async getGRULiquidity(): Promise<GRULiquidityMetrics> {
// Get GRU units
const gruUnits = await prisma.gru_units.findMany({
where: { status: 'active' },
});
try {
await prisma.gru_units.findMany({
where: { status: 'active' },
});
// Calculate in circulation by class
const inCirculation = {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
};
const inCirculation = {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
};
// Get GRU indexes for price
const indexes = await prisma.gru_indexes.findMany({
where: { status: 'active' },
include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } },
});
const indexes = await prisma.gru_indexes.findMany({
where: { status: 'active' },
include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } },
});
let currentPrice = 1.0; // Default
let volatility = 0.0;
let currentPrice = 1.0;
let volatility = 0.0;
if (indexes.length > 0 && (indexes[0] as { gru_index_price_history?: Array<{ indexValue?: unknown }> }).gru_index_price_history?.length >= 2) {
const priceHistory = (indexes[0] as { gru_index_price_history: Array<{ indexValue?: unknown }> }).gru_index_price_history;
const [latest, previous] = priceHistory;
currentPrice = parseFloat(String(latest?.indexValue ?? 1));
const prevPrice = previous ? parseFloat(String(previous.indexValue ?? 1)) : currentPrice;
volatility = prevPrice > 0 ? Math.abs((currentPrice - prevPrice) / prevPrice) : 0;
const latestIndex = indexes[0] as
| { gru_index_price_history?: Array<{ indexValue?: unknown }> }
| undefined;
if (latestIndex?.gru_index_price_history && latestIndex.gru_index_price_history.length >= 2) {
const priceHistory = latestIndex.gru_index_price_history;
const [latest, previous] = priceHistory;
currentPrice = parseFloat(String(latest?.indexValue ?? 1));
const prevPrice = previous ? parseFloat(String(previous.indexValue ?? 1)) : currentPrice;
volatility = prevPrice > 0 ? Math.abs((currentPrice - prevPrice) / prevPrice) : 0;
}
return {
currentPrice,
volatility,
inCirculation,
};
} catch (error) {
logger.warn('GRU liquidity metrics unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
currentPrice: 0,
volatility: 0,
inCirculation: {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
},
};
}
return {
currentPrice,
volatility,
inCirculation,
};
}
/**
* Get risk flags and alerts
*/
async getRiskFlags(): Promise<RiskFlags> {
const dashboard = await dashboardService.getIncidentAlertsDashboard();
try {
const dashboard = await dashboardService.getIncidentAlertsDashboard();
const alerts = dashboard.incidentAlerts || [];
const high = alerts.filter((a: any) => a.severity === 'critical' || a.severity === 'high').length;
const medium = alerts.filter((a: any) => a.severity === 'medium').length;
const low = alerts.filter((a: any) => a.severity === 'low').length;
const alerts = dashboard.incidentAlerts || [];
const high = alerts.filter((a: any) => a.severity === 'critical' || a.severity === 'high').length;
const medium = alerts.filter((a: any) => a.severity === 'medium').length;
const low = alerts.filter((a: any) => a.severity === 'low').length;
return {
high,
medium,
low,
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({
id: a.id || `alert-${idx}`,
type: a.type || 'unknown',
severity: a.severity || 'low',
description: a.description || '',
timestamp: a.timestamp || new Date(),
})),
};
return {
high,
medium,
low,
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({
id: a.id || `alert-${idx}`,
type: a.type || 'unknown',
severity: a.severity || 'low',
description: a.description || '',
timestamp: a.timestamp || new Date(),
})),
};
} catch (error) {
logger.warn('Risk flags unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
high: 0,
medium: 0,
low: 0,
alerts: [],
};
}
}
/**
* Get SCB status table
*/
async getSCBStatus(): Promise<SCBStatus[]> {
const scbs = await prisma.sovereign_banks.findMany({
where: { status: { in: ['active', 'suspended'] } },
});
try {
const scbs = await prisma.sovereign_banks.findMany({
where: { status: { in: ['active', 'suspended'] } },
});
const scbStatus: SCBStatus[] = [];
const scbStatus: SCBStatus[] = [];
for (const scb of scbs) {
// Get recent settlements to determine connectivity
const recentSettlements = await prisma.atomic_settlements.findMany({
where: {
OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }],
createdAt: {
gte: new Date(Date.now() - 5 * 60 * 1000), // Last 5 minutes
for (const scb of scbs) {
const recentSettlements = await prisma.atomic_settlements.findMany({
where: {
OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }],
createdAt: {
gte: new Date(Date.now() - 5 * 60 * 1000),
},
},
},
take: 10,
});
take: 10,
});
const connectivity =
recentSettlements.length > 0 ? 'connected' : 'degraded';
const connectivity =
recentSettlements.length > 0 ? 'connected' : 'degraded';
// Get open incidents (SRI enforcements)
const openIncidents = await prisma.sri_enforcements.count({
where: {
sovereignBankId: scb.id,
status: 'active',
},
});
const openIncidents = await prisma.sri_enforcements.count({
where: {
sovereignBankId: scb.id,
status: 'active',
},
});
scbStatus.push({
scbId: scb.id,
name: scb.name,
country: scb.sovereignCode,
bic: scb.bic || undefined,
status: scb.status,
connectivity,
openIncidents,
scbStatus.push({
scbId: scb.id,
name: scb.name,
country: scb.sovereignCode,
bic: scb.bic || undefined,
status: scb.status,
connectivity,
openIncidents,
});
}
return scbStatus;
} catch (error) {
logger.warn('SCB status unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return [];
}
return scbStatus;
}
}
export const globalOverviewService = new GlobalOverviewService();

View File

@@ -103,7 +103,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_ISSUANCE_PROPOSAL),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.createIssuanceProposal(
employeeId,
req.body
@@ -120,7 +120,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_LOCK_UNLOCK),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.lockUnlockGRUClass(employeeId, req.body);
return res.json(result);
} catch (error) {
@@ -134,7 +134,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_CIRCUIT_BREAKERS),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.setCircuitBreakers(employeeId, req.body);
return res.json(result);
} catch (error) {
@@ -148,7 +148,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_BOND_ISSUANCE_WINDOW),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.manageBondIssuanceWindow(
employeeId,
req.body
@@ -165,7 +165,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_BOND_BUYBACK),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const { bondId, amount } = req.body;
const result = await dbisAdminService.gruControls.triggerEmergencyBuyback(
employeeId,
@@ -241,7 +241,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_ADJUST_CAPS),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.adjustCorridorCaps(
employeeId,
req.body
@@ -258,7 +258,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_THROTTLE),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.throttleCorridor(
employeeId,
req.body
@@ -275,7 +275,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_ENABLE_DISABLE),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.enableDisableCorridor(
employeeId,
req.body
@@ -293,7 +293,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_QUIESCE_SUBSYSTEM),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.quiesceSubsystem(employeeId, req.body);
return res.json(result);
} catch (error) {
@@ -307,7 +307,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_KILL_SWITCH),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.activateKillSwitch(
employeeId,
req.body
@@ -324,7 +324,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_ESCALATE_INCIDENT),
async (req, res, next) => {
try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || '';
const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.escalateIncident(
employeeId,
req.body
@@ -337,4 +337,3 @@ router.post(
);
export default router;

View File

@@ -88,6 +88,8 @@ export interface SCBOverviewDashboard {
}
export class SCBOverviewService {
private readonly allowPlaceholderMetrics = process.env.DBIS_ENABLE_PLACEHOLDER_METRICS === 'true';
/**
* Get SCB overview dashboard
*/
@@ -121,22 +123,23 @@ export class SCBOverviewService {
new Decimal(0)
).toNumber();
// Get payment rails (placeholder)
const paymentRails = [
{
railType: 'RTGS',
status: 'active' as const,
volume24h: 0,
},
{
railType: 'CBDC',
status: 'active' as const,
volume24h: cbdcInCirculation,
},
];
const paymentRails = this.allowPlaceholderMetrics
? [
{
railType: 'RTGS',
status: 'active' as const,
volume24h: 0,
},
{
railType: 'CBDC',
status: 'active' as const,
volume24h: cbdcInCirculation,
},
]
: [];
return {
fiCount: 0, // Would query FI table
fiCount: 0,
activeFIs: 0,
paymentRails,
cbdcStatus: {
@@ -148,7 +151,7 @@ export class SCBOverviewService {
},
},
nostroVostroStatus: {
totalAccounts: 0, // Would query Nostro/Vostro accounts
totalAccounts: 0,
activeAccounts: 0,
apiEnabled: true,
},
@@ -286,4 +289,3 @@ export class SCBOverviewService {
}
export const scbOverviewService = new SCBOverviewService();

View File

@@ -52,7 +52,7 @@ router.post(
if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' });
}
const employeeId = req.headers['x-employee-id'] as string || scbId;
const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.approveSuspendFI(
employeeId,
scbId,
@@ -74,7 +74,7 @@ router.post(
if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' });
}
const employeeId = req.headers['x-employee-id'] as string || scbId;
const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.setFILimits(employeeId, scbId, req.body);
return res.json(result);
} catch (error) {
@@ -92,7 +92,7 @@ router.post(
if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' });
}
const employeeId = req.headers['x-employee-id'] as string || scbId;
const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.assignAPIProfile(
employeeId,
scbId,
@@ -133,7 +133,7 @@ router.post(
if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' });
}
const employeeId = req.headers['x-employee-id'] as string || scbId;
const employeeId = req.employeeId || scbId;
const result = await scbAdminService.cbdcControls.updateCBDCParameters(
employeeId,
scbId,
@@ -155,7 +155,7 @@ router.post(
if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' });
}
const employeeId = req.headers['x-employee-id'] as string || scbId;
const employeeId = req.employeeId || scbId;
const result = await scbAdminService.cbdcControls.updateGRUPolicy(
employeeId,
scbId,
@@ -169,4 +169,3 @@ router.post(
);
export default router;

View File

@@ -0,0 +1,259 @@
import { DbisError, ErrorCode } from '@/shared/types';
import { logger } from '@/infrastructure/monitoring/logger';
export interface LeiRegistryRecord {
lei: string;
legalName: string | null;
legalCountry: string | null;
registrationStatus: string | null;
entityStatus: string | null;
initialRegistrationDate: string | null;
nextRenewalDate: string | null;
managingLou: string | null;
}
export interface LeiValidationResult {
normalizedLei: string;
registryValidated: boolean;
legalName: string | null;
legalCountry: string | null;
registrationStatus: string | null;
entityStatus: string | null;
initialRegistrationDate: string | null;
nextRenewalDate: string | null;
managingLou: string | null;
validatedAt: string;
source: 'gleif' | 'format-only';
}
interface RegistrationValidationRequest {
lei?: string;
institutionName: string;
country: string;
}
function normalizeLei(input: string): string {
return input.trim().toUpperCase().replace(/\s+/g, '');
}
function normalizeCountry(input: string): string {
return input.trim().toUpperCase();
}
function normalizeEntityName(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/&/g, ' AND ')
.replace(/[^A-Z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function alphaNumericToIso7064Digits(input: string): string {
return input
.split('')
.map((char) => {
if (/[0-9]/.test(char)) {
return char;
}
const code = char.charCodeAt(0);
if (code >= 65 && code <= 90) {
return String(code - 55);
}
throw new DbisError(ErrorCode.VALIDATION_ERROR, 'LEI contains invalid characters');
})
.join('');
}
function iso7064Mod97(value: string): number {
let remainder = 0;
for (const char of value) {
remainder = (remainder * 10 + Number(char)) % 97;
}
return remainder;
}
function isValidLeiChecksum(lei: string): boolean {
const rearranged = `${lei.slice(4)}${lei.slice(0, 4)}`;
const numeric = alphaNumericToIso7064Digits(rearranged);
return iso7064Mod97(numeric) === 1;
}
export class LeiValidationService {
private readonly apiBaseUrl = (process.env.GLEIF_API_BASE_URL || 'https://api.gleif.org/api/v1').replace(/\/$/, '');
private readonly timeoutMs = Number(process.env.DBIS_LEI_LOOKUP_TIMEOUT_MS || '5000');
private readonly requireRegistryValidation = process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION !== 'false';
private readonly allowFormatOnlyFallback = process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK === 'true';
validateLeiFormat(rawLei?: string): string {
if (!rawLei?.trim()) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'A Legal Entity Identifier (LEI) is required for new international financial entity registration'
);
}
const lei = normalizeLei(rawLei);
if (!/^[A-Z0-9]{20}$/.test(lei)) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, 'LEI must be exactly 20 alphanumeric characters');
}
return lei;
}
async validateRegistrationCandidate(
request: RegistrationValidationRequest
): Promise<LeiValidationResult> {
const normalizedLei = this.validateLeiFormat(request.lei);
const requestedName = normalizeEntityName(request.institutionName);
const requestedCountry = normalizeCountry(request.country);
const registryRecord = await this.lookupLeiRecord(normalizedLei);
if (!registryRecord) {
if (this.requireRegistryValidation && !this.allowFormatOnlyFallback) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} could not be validated against the registry`
);
}
return {
normalizedLei,
registryValidated: false,
legalName: null,
legalCountry: null,
registrationStatus: null,
entityStatus: null,
initialRegistrationDate: null,
nextRenewalDate: null,
managingLou: null,
validatedAt: new Date().toISOString(),
source: 'format-only',
};
}
if (registryRecord.registrationStatus && registryRecord.registrationStatus !== 'ISSUED') {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} is not currently issued`,
{ registrationStatus: registryRecord.registrationStatus }
);
}
if (registryRecord.entityStatus && registryRecord.entityStatus !== 'ACTIVE') {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} is not associated with an active legal entity`,
{ entityStatus: registryRecord.entityStatus }
);
}
if (registryRecord.legalName) {
const normalizedLegalName = normalizeEntityName(registryRecord.legalName);
if (normalizedLegalName !== requestedName) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'Registered party name does not match the LEI legal name',
{
providedName: request.institutionName,
leiLegalName: registryRecord.legalName,
}
);
}
}
if (registryRecord.legalCountry && normalizeCountry(registryRecord.legalCountry) !== requestedCountry) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'Registered party country does not match the LEI legal address country',
{
providedCountry: requestedCountry,
leiCountry: registryRecord.legalCountry,
}
);
}
return {
normalizedLei,
registryValidated: true,
legalName: registryRecord.legalName,
legalCountry: registryRecord.legalCountry,
registrationStatus: registryRecord.registrationStatus,
entityStatus: registryRecord.entityStatus,
initialRegistrationDate: registryRecord.initialRegistrationDate,
nextRenewalDate: registryRecord.nextRenewalDate,
managingLou: registryRecord.managingLou,
validatedAt: new Date().toISOString(),
source: 'gleif',
};
}
private async lookupLeiRecord(lei: string): Promise<LeiRegistryRecord | null> {
const url = `${this.apiBaseUrl}/lei-records/${encodeURIComponent(lei)}`;
try {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(this.timeoutMs),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
logger.warn('LEI registry lookup returned non-success status', {
lei,
status: response.status,
statusText: response.statusText,
});
return null;
}
const payload = (await response.json()) as {
data?: {
id?: string;
attributes?: {
entity?: {
legalName?: { name?: string | null };
legalAddress?: { country?: string | null };
status?: string | null;
};
registration?: {
status?: string | null;
initialRegistrationDate?: string | null;
nextRenewalDate?: string | null;
managingLou?: string | null;
};
};
};
};
const attributes = payload.data?.attributes;
return {
lei: payload.data?.id || lei,
legalName: attributes?.entity?.legalName?.name || null,
legalCountry: attributes?.entity?.legalAddress?.country || null,
registrationStatus: attributes?.registration?.status || null,
entityStatus: attributes?.entity?.status || null,
initialRegistrationDate: attributes?.registration?.initialRegistrationDate || null,
nextRenewalDate: attributes?.registration?.nextRenewalDate || null,
managingLou: attributes?.registration?.managingLou || null,
};
} catch (error) {
logger.warn('LEI registry lookup failed', {
lei,
url,
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
}
export const leiValidationService = new LeiValidationService();

View File

@@ -7,6 +7,10 @@
import { Router } from 'express';
import { zeroTrustAuthMiddleware } from '@/integration/api-gateway/middleware/auth.middleware';
import {
validateRequest,
nostroVostroValidationSchemas,
} from '@/integration/api-gateway/middleware/validation.middleware';
import { nostroVostroService } from './nostro-vostro.service';
import { reconciliationService } from './reconciliation.service';
import { webhookService } from './webhook.service';
@@ -177,7 +181,7 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* example:
* name: "Central Bank of Example"
* bic: "CBEXUS33"
* lei: "5493000X9ZXSQ9B6Y815"
* lei: "5493001KJTIIGC8Y1R35"
* country: "US"
* regulatoryTier: "SCB"
* responses:
@@ -197,19 +201,24 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* 401:
* $ref: '#/components/responses/Unauthorized'
*/
router.post('/participants', zeroTrustAuthMiddleware, async (req, res, next) => {
try {
const participant = await nostroVostroService.createParticipant(req.body);
router.post(
'/participants',
zeroTrustAuthMiddleware,
validateRequest({ body: nostroVostroValidationSchemas.participantCreateRequest }),
async (req, res, next) => {
try {
const participant = await nostroVostroService.createParticipant(req.body);
res.status(201).json({
success: true,
data: participant,
timestamp: new Date(),
});
} catch (error) {
res.status(201).json({
success: true,
data: participant,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
}
});
);
// ============================================================================
// Account Endpoints
@@ -858,4 +867,3 @@ router.post('/webhooks/events', async (req, res, next) => {
});
export default router;

View File

@@ -17,6 +17,7 @@ import {
TransferStatus,
SettlementAsset,
} from './nostro-vostro.types';
import { leiValidationService } from './lei-validation.service';
export class NostroVostroService {
// ============================================================================
@@ -27,7 +28,15 @@ export class NostroVostroService {
* Create a new participant
*/
async createParticipant(request: ParticipantCreateRequest): Promise<Participant> {
const participantId = request.participantId || `PART-${uuidv4()}`;
const participantId = request.participantId?.trim() || `PART-${uuidv4()}`;
const normalizedBic = request.bic?.trim().toUpperCase();
const normalizedCountry = request.country.trim().toUpperCase();
const normalizedLei = leiValidationService.validateLeiFormat(request.lei);
const leiValidation = await leiValidationService.validateRegistrationCandidate({
lei: normalizedLei,
institutionName: request.name,
country: normalizedCountry,
});
// Check if participantId already exists
const existing = await prisma.nostro_vostro_participants.findUnique({
@@ -39,36 +48,40 @@ export class NostroVostroService {
}
// Check BIC uniqueness if provided
if (request.bic) {
if (normalizedBic) {
const existingBic = await prisma.nostro_vostro_participants.findUnique({
where: { bic: request.bic },
where: { bic: normalizedBic },
});
if (existingBic) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with BIC ${request.bic} already exists`);
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with BIC ${normalizedBic} already exists`);
}
}
// Check LEI uniqueness if provided
if (request.lei) {
const existingLei = await prisma.nostro_vostro_participants.findUnique({
where: { lei: request.lei },
});
if (existingLei) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${request.lei} already exists`);
}
const existingLei = await prisma.nostro_vostro_participants.findUnique({
where: { lei: normalizedLei },
});
if (existingLei) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${normalizedLei} already exists`);
}
const metadata = {
...(request.metadata || {}),
leiValidation,
} as unknown as Prisma.InputJsonValue;
const participant = await prisma.nostro_vostro_participants.create({
data: {
id: uuidv4(),
participantId,
name: request.name,
bic: request.bic,
lei: request.lei,
country: request.country,
bic: normalizedBic,
lei: normalizedLei,
country: normalizedCountry,
regulatoryTier: request.regulatoryTier,
sovereignBankId: request.sovereignBankId,
status: 'active',
metadata: (request.metadata || {}) as Prisma.InputJsonValue,
metadata,
updatedAt: new Date(),
},
});
@@ -163,6 +176,7 @@ export class NostroVostroService {
const account = await prisma.nostro_vostro_accounts.create({
data: {
id: uuidv4(),
accountId,
ownerParticipantId: request.ownerParticipantId,
counterpartyParticipantId: request.counterpartyParticipantId,
@@ -173,7 +187,8 @@ export class NostroVostroService {
currentBalance: new Decimal(0),
availableLiquidity: new Decimal(0),
holdAmount: new Decimal(0),
metadata: (request.metadata || {}) as Prisma.InputJsonValue,
metadata: (request.metadata || {}) as unknown as Prisma.InputJsonValue,
updatedAt: new Date(),
},
});
@@ -593,4 +608,3 @@ export class NostroVostroService {
}
export const nostroVostroService = new NostroVostroService();

View File

@@ -51,7 +51,7 @@ export interface ScreenAccess {
}
export interface ActionPermissions {
[module: string]: Action[];
[module: string]: Array<Action | '*'>;
}
export interface ApprovalRequirement {
@@ -164,4 +164,3 @@ export interface ResourceContext {
currency?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -109,9 +109,9 @@ export class RbacEngineService {
participant: ParticipantType;
accessLevel: string;
} | null> {
const employee = await prisma.employeeCredential.findUnique({
const employee = await prisma.employee_credentials.findUnique({
where: { employeeId },
include: { role: true },
include: { dbis_roles: true },
});
if (!employee || employee.status !== 'active') {
@@ -119,7 +119,7 @@ export class RbacEngineService {
}
// Determine participant type from role name
const roleName = employee.role.roleName;
const roleName = employee.dbis_roles.roleName;
let participant: ParticipantType = 'DBIS';
if (roleName.startsWith('SCB_')) {
@@ -131,7 +131,7 @@ export class RbacEngineService {
return {
roleName,
participant,
accessLevel: employee.role.accessLevel,
accessLevel: employee.dbis_roles.accessLevel,
};
}
@@ -421,7 +421,8 @@ export class RbacEngineService {
return [];
}
return roleDef.actions[module] || roleDef.actions['*'] || [];
const actions = roleDef.actions[module] || roleDef.actions['*'] || [];
return actions.filter((action): action is Action => action !== '*');
}
/**
@@ -520,4 +521,3 @@ export class RbacEngineService {
}
export const rbacEngineService = new RbacEngineService();

View File

@@ -115,6 +115,7 @@ export class RoleManagementService {
accessLevel: roleData.accessLevel,
permissions: roleData.permissions,
status: 'active',
updatedAt: new Date(),
},
});
}
@@ -134,6 +135,7 @@ export class RoleManagementService {
accessLevel: roleData.accessLevel,
permissions: roleData.permissions,
status: 'active',
updatedAt: new Date(),
},
});
}
@@ -144,7 +146,7 @@ export class RoleManagementService {
async getRole(roleId: string) {
return await prisma.dbis_roles.findUnique({
where: { roleId },
include: { employees: true },
include: { employee_credentials: true },
});
}
@@ -183,14 +185,14 @@ export class RoleManagementService {
async hasPermission(employeeId: string, permission: string): Promise<boolean> {
const employee = await prisma.employee_credentials.findUnique({
where: { employeeId },
include: { dbis_roles: true },
include: { dbis_roles: true },
});
if (!employee || employee.status !== 'active') {
return false;
}
const permissions = employee.role.permissions as string[];
const permissions = employee.dbis_roles.permissions as string[];
return permissions.includes('all') || permissions.includes(permission);
}
@@ -200,10 +202,10 @@ export class RoleManagementService {
async getAccessLevel(employeeId: string): Promise<string | null> {
const employee = await prisma.employee_credentials.findUnique({
where: { employeeId },
include: { dbis_roles: true },
include: { dbis_roles: true },
});
return employee?.role.accessLevel || null;
return employee?.dbis_roles.accessLevel || null;
}
/**
@@ -260,14 +262,14 @@ export class RoleManagementService {
} | null> {
const employee = await prisma.employee_credentials.findUnique({
where: { employeeId },
include: { dbis_roles: true },
include: { dbis_roles: true },
});
if (!employee || employee.status !== 'active') {
return null;
}
const roleName = employee.role.roleName;
const roleName = employee.dbis_roles.roleName;
let participant: ParticipantType = 'DBIS';
if (roleName.startsWith('SCB_')) {
@@ -284,11 +286,10 @@ export class RoleManagementService {
return {
roleName,
participant,
accessLevel: employee.role.accessLevel,
accessLevel: employee.dbis_roles.accessLevel,
description: roleDef.description,
};
}
}
export const roleManagementService = new RoleManagementService();

View File

@@ -23,6 +23,16 @@ export interface MessageEncryption {
}
export class As4SecurityService {
private ensureSecureImplementationEnabled(operation: string): void {
if (process.env.DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS === 'true') {
return;
}
throw new Error(
`AS4 ${operation} is disabled because only placeholder cryptography is implemented. Set DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS=true for non-production testing only.`
);
}
/**
* Validate replay nonce
*/
@@ -99,8 +109,10 @@ export class As4SecurityService {
throw new Error(`Member ${memberId} not found or no signing certificate`);
}
this.ensureSecureImplementationEnabled('signing');
// TODO: Implement actual signing with HSM or certificate
// For now, create a placeholder signature
// Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const sign = createSign(algorithm === 'RSA-SHA256' ? 'RSA-SHA256' : 'sha256');
sign.update(messagePayload);
sign.end();
@@ -144,8 +156,10 @@ export class As4SecurityService {
return false;
}
this.ensureSecureImplementationEnabled('signature verification');
// TODO: Implement actual signature verification
// For now, verify hash-based signature
// Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const expectedSignature = createHash('sha256')
.update(messagePayload + member.signingCertFingerprint)
.digest('hex');
@@ -167,8 +181,10 @@ export class As4SecurityService {
throw new Error(`Recipient ${recipientMemberId} not found or no encryption certificate`);
}
this.ensureSecureImplementationEnabled('encryption');
// TODO: Implement actual encryption with recipient's public key
// For now, return placeholder
// Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const encryptedData = Buffer.from(messagePayload).toString('base64');
return {
@@ -200,8 +216,10 @@ export class As4SecurityService {
throw new Error('Certificate fingerprint mismatch');
}
this.ensureSecureImplementationEnabled('decryption');
// TODO: Implement actual decryption with private key
// For now, decode base64
// Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
return Buffer.from(encryptedMessage.encryptedData, 'base64').toString('utf-8');
}

View File

@@ -17,24 +17,107 @@ import { expressionEvaluator } from './expression-evaluator';
import { entitlementsService } from '../entitlements/entitlements.service';
import { capabilityRegistryService } from '../registry/capability-registry.service';
// Redis client for caching (will be initialized if Redis is available)
let redisClient: any = null;
try {
// Try to import Redis if available
const redis = require('redis');
if (process.env.REDIS_URL) {
redisClient = redis.createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(() => {
logger.warn('Redis connection failed, policy decisions will not be cached');
});
type PolicyRedisClient = {
isOpen?: boolean;
connect(): Promise<void>;
get(key: string): Promise<string | null>;
setEx(key: string, ttlSeconds: number, value: string): Promise<unknown>;
keys(pattern: string): Promise<string[]>;
del(keys: string | string[]): Promise<unknown>;
on(event: 'error', listener: (error: Error) => void): void;
};
let redisClient: PolicyRedisClient | null = null;
let redisModuleChecked = false;
let redisModuleAvailable = false;
let redisConnectPromise: Promise<PolicyRedisClient | null> | null = null;
let redisMissingLogged = false;
let redisConnectionFailureLogged = false;
function loadRedisModule():
| {
createClient(options: { url: string }): PolicyRedisClient;
}
| null {
if (redisModuleChecked) {
return redisModuleAvailable ? require('redis') : null;
}
redisModuleChecked = true;
try {
const redis = require('redis') as { createClient(options: { url: string }): PolicyRedisClient };
redisModuleAvailable = true;
return redis;
} catch (error) {
if (!redisMissingLogged) {
logger.warn('Redis client module is unavailable, policy decisions will not be cached', {
error: error instanceof Error ? error.message : String(error),
});
redisMissingLogged = true;
}
redisModuleAvailable = false;
return null;
}
} catch {
logger.warn('Redis not available, policy decisions will not be cached');
}
export class PolicyEngineService {
private readonly CACHE_TTL = 120; // 2 minutes default TTL
private async getRedisClient(): Promise<PolicyRedisClient | null> {
const redisUrl = process.env.REDIS_URL?.trim();
if (!redisUrl) {
return null;
}
if (redisClient?.isOpen) {
return redisClient;
}
if (redisConnectPromise) {
return redisConnectPromise;
}
const redis = loadRedisModule();
if (!redis) {
return null;
}
redisConnectPromise = (async () => {
try {
const client = redisClient ?? redis.createClient({ url: redisUrl });
if (!redisClient) {
client.on('error', (error) => {
logger.warn('Redis client error, policy cache unavailable', {
error: error.message,
});
});
}
if (!client.isOpen) {
await client.connect();
}
redisClient = client;
redisConnectionFailureLogged = false;
return redisClient;
} catch (error) {
redisClient = null;
if (!redisConnectionFailureLogged) {
logger.warn('Redis connection failed, policy decisions will not be cached', {
error: error instanceof Error ? error.message : String(error),
});
redisConnectionFailureLogged = true;
}
return null;
} finally {
redisConnectPromise = null;
}
})();
return redisConnectPromise;
}
/**
* Make a policy decision
*/
@@ -299,12 +382,13 @@ export class PolicyEngineService {
* Get cached decision
*/
private async getCachedDecision(cacheKey: string): Promise<PolicyDecisionResponse | null> {
if (!redisClient) {
const client = await this.getRedisClient();
if (!client) {
return null;
}
try {
const cached = await redisClient.get(cacheKey);
const cached = await client.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
@@ -319,12 +403,13 @@ export class PolicyEngineService {
* Cache a decision
*/
private async cacheDecision(cacheKey: string, decision: PolicyDecisionResponse): Promise<void> {
if (!redisClient) {
const client = await this.getRedisClient();
if (!client) {
return;
}
try {
await redisClient.setEx(cacheKey, this.CACHE_TTL, JSON.stringify(decision));
await client.setEx(cacheKey, this.CACHE_TTL, JSON.stringify(decision));
} catch (error) {
logger.warn('Failed to cache decision', { error });
}
@@ -334,16 +419,17 @@ export class PolicyEngineService {
* Invalidate cache for a capability
*/
private async invalidateCache(capabilityId: string): Promise<void> {
if (!redisClient) {
const client = await this.getRedisClient();
if (!client) {
return;
}
try {
// Use pattern matching to find all keys for this capability
const pattern = `policy:decision:*:*:${capabilityId}:*`;
const keys = await redisClient.keys(pattern);
const keys = await client.keys(pattern);
if (keys.length > 0) {
await redisClient.del(keys);
await client.del(keys);
}
} catch (error) {
logger.warn('Failed to invalidate cache', { error });

View File

@@ -1,13 +1,10 @@
// DBIS Core Banking System - Main Entry Point
import dotenv from 'dotenv';
import './bootstrap/env';
import app from './integration/api-gateway/app';
import { logger } from './infrastructure/monitoring/logger';
import { validateEnvironment } from './shared/config/env-validator';
// Load environment variables
dotenv.config();
// Validate environment variables before starting
try {
validateEnvironment();
@@ -37,4 +34,3 @@ process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
process.exit(0);
});

View File

@@ -11,6 +11,8 @@ export interface ProxmoxConfig {
username: string;
password: string;
realm?: string;
tokenName?: string;
tokenValue?: string;
}
export interface ContainerSpec {
@@ -57,6 +59,17 @@ export class ProxmoxVEIntegration {
* Authenticate with Proxmox VE
*/
async authenticate(): Promise<void> {
if (this.config.tokenName && this.config.tokenValue) {
this.token = `PVEAPIToken=${this.config.username}!${this.config.tokenName}=${this.config.tokenValue}`;
this.tokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
logger.info('Proxmox VE token authentication configured', {
host: this.config.host,
username: this.config.username,
tokenName: this.config.tokenName,
});
return;
}
await proxmoxCircuitBreaker.execute(async () => {
return await retryWithBackoff(
async () => {
@@ -240,7 +253,9 @@ export class ProxmoxVEIntegration {
const options: RequestInit = {
method,
headers: {
'Cookie': `PVEAuthCookie=${this.token}`,
...(this.config.tokenName && this.config.tokenValue
? { Authorization: this.token || '' }
: { 'Cookie': `PVEAuthCookie=${this.token}` }),
},
};
@@ -281,19 +296,22 @@ export class ProxmoxVEIntegration {
// Validate required Proxmox environment variables
function getProxmoxConfig(): ProxmoxConfig {
const host = process.env.PROXMOX_HOST;
const username = process.env.PROXMOX_USERNAME;
const username = process.env.PROXMOX_USERNAME || process.env.PROXMOX_USER;
const password = process.env.PROXMOX_PASSWORD;
const tokenName = process.env.PROXMOX_TOKEN_NAME;
const tokenValue = process.env.PROXMOX_TOKEN_VALUE;
if (process.env.NODE_ENV === 'production') {
if (!host) {
throw new Error('PROXMOX_HOST environment variable is required in production');
}
if (!username) {
throw new Error('PROXMOX_USERNAME environment variable is required in production');
}
if (!password) {
throw new Error('PROXMOX_PASSWORD environment variable is required in production');
}
const hasPasswordAuth = Boolean(host && username && password);
const hasTokenAuth = Boolean(host && username && tokenName && tokenValue);
if (process.env.NODE_ENV === 'production' && !hasPasswordAuth && !hasTokenAuth) {
logger.warn('Proxmox integration environment is incomplete; IRU deployment features will remain unavailable', {
hasHost: Boolean(host),
hasUsername: Boolean(username),
hasPassword: Boolean(password),
hasTokenName: Boolean(tokenName),
hasTokenValue: Boolean(tokenValue),
});
}
return {
@@ -302,6 +320,8 @@ function getProxmoxConfig(): ProxmoxConfig {
username: username || 'root',
password: password || '',
realm: process.env.PROXMOX_REALM || 'pam',
tokenName,
tokenValue,
};
}

View File

@@ -1,28 +1,18 @@
// Express.js API Gateway Application
import '@/bootstrap/env';
import express, { Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
import swaggerJsdoc from 'swagger-jsdoc';
import { zeroTrustAuthMiddleware, optionalAuthMiddleware } from './middleware/auth.middleware';
import { zeroTrustAuthMiddleware } from './middleware/auth.middleware';
import { dynamicRateLimitMiddleware } from './middleware/rate-limit.middleware';
import { errorHandler } from './middleware/error.middleware';
import { auditLogMiddleware } from './middleware/audit.middleware';
import { validateEnvironment } from '@/shared/config/env-validator';
import { logger } from '@/infrastructure/monitoring/logger';
import { tracingMiddleware } from '@/infrastructure/monitoring/tracing.middleware';
// Validate environment variables at startup (fail fast)
try {
validateEnvironment();
logger.info('Environment validation passed');
} catch (error) {
logger.error('Environment validation failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
process.exit(1);
}
import authRoutes from './routes/auth.routes';
// Import route handlers (will be created)
// import paymentRoutes from '@/core/payments/payment.routes';
@@ -285,6 +275,9 @@ app.get(['/health', '/v1/health'], async (req, res) => {
res.status(statusCode).json(healthStatus);
});
// Portal auth routes: public login plus session bootstrap/logout.
app.use('/api/auth', authRoutes);
// IRU Marketplace routes (public endpoints, auth handled per-route)
app.use('/api/v1/iru/marketplace', iruMarketplaceRoutes);
@@ -320,8 +313,11 @@ app.use('/api/v1/iru/metrics', iruMetricsRoutes);
import adminCentralRoutes from '@/integration/api-gateway/routes/admin-central.routes';
app.use('/api/admin/central', adminCentralRoutes);
// Public AS4 metrics route for Prometheus.
import as4MetricsRoutes from '@/core/settlement/as4/as4-metrics.routes';
app.use('/api/v1/as4', as4MetricsRoutes);
// API routes (protected)
// All API routes require authentication
app.use('/api', zeroTrustAuthMiddleware);
app.use('/api', dynamicRateLimitMiddleware);
@@ -365,11 +361,9 @@ app.use('/api/v1/routes', tezosUsdtzRoutes);
import as4GatewayRoutes from '@/core/settlement/as4/as4.routes';
import as4MemberDirectoryRoutes from '@/core/settlement/as4-settlement/member-directory/member-directory.routes';
import as4SettlementRoutes from '@/core/settlement/as4-settlement/as4-settlement.routes';
import as4MetricsRoutes from '@/core/settlement/as4/as4-metrics.routes';
app.use('/api/v1/as4/gateway', as4GatewayRoutes);
app.use('/api/v1/as4/directory', as4MemberDirectoryRoutes);
app.use('/api/v1/as4/settlement', as4SettlementRoutes);
app.use('/api/v1/as4', as4MetricsRoutes); // Metrics endpoint (public for Prometheus)
// Volume V routes
app.use('/api/v1/gbig', gbigRoutes);

View File

@@ -12,17 +12,19 @@ export function requireAdminCentralKey(req: Request, res: Response, next: NextFu
if (!expected) {
// If not configured, allow (dev) or deny (prod). Prefer deny for security.
return res.status(501).json({
res.status(501).json({
success: false,
error: { code: 'NOT_CONFIGURED', message: 'Admin central API key not configured' },
});
return;
}
if (!key || key !== expected) {
return res.status(401).json({
res.status(401).json({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid or missing X-Admin-Central-Key' },
});
return;
}
next();

View File

@@ -13,13 +13,11 @@ import { DbisError, ErrorCode } from '@/shared/types';
export function requireAdminPermission(permission: AdminPermission) {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try {
// Get employee ID from header or use sovereignBankId as fallback
// In production, this would come from JWT token or employee credential
const employeeId = (req.headers['x-employee-id'] as string) || req.sovereignBankId;
const employeeId = req.employeeId;
if (!employeeId) {
throw new DbisError(
ErrorCode.UNAUTHORIZED,
'Employee ID or Sovereign Bank ID required for admin operations'
'Employee identity required for admin operations'
);
}
@@ -37,7 +35,7 @@ export function requireAdminPermission(permission: AdminPermission) {
next();
} catch (error) {
if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false,
error: {
code: error.code,
@@ -45,8 +43,9 @@ export function requireAdminPermission(permission: AdminPermission) {
},
timestamp: new Date(),
});
return;
} else {
return res.status(500).json({
res.status(500).json({
success: false,
error: {
code: ErrorCode.INTERNAL_ERROR,
@@ -54,6 +53,7 @@ export function requireAdminPermission(permission: AdminPermission) {
},
timestamp: new Date(),
});
return;
}
}
};
@@ -65,11 +65,11 @@ export function requireAdminPermission(permission: AdminPermission) {
export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try {
const employeeId = (req.headers['x-employee-id'] as string) || req.sovereignBankId;
const employeeId = req.employeeId;
if (!employeeId) {
throw new DbisError(
ErrorCode.UNAUTHORIZED,
'Employee ID or Sovereign Bank ID required'
'Employee identity required'
);
}
@@ -92,7 +92,7 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
next();
} catch (error) {
if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false,
error: {
code: error.code,
@@ -100,8 +100,9 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
},
timestamp: new Date(),
});
return;
} else {
return res.status(500).json({
res.status(500).json({
success: false,
error: {
code: ErrorCode.INTERNAL_ERROR,
@@ -109,6 +110,7 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
},
timestamp: new Date(),
});
return;
}
}
};

View File

@@ -10,10 +10,21 @@ import { getEnv } from '@/shared/config/env-validator';
export interface AuthenticatedRequest extends Request {
sovereignBankId?: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
identityType?: string;
apiRole?: string;
}
function isPortalSession(payload: JwtPayload): boolean {
return payload.sessionType === 'portal' || payload.identityType === 'WEB_PORTAL';
}
/**
* Extract Sovereign Identity Token (SIT) from Authorization header
*/
@@ -144,23 +155,43 @@ export async function zeroTrustAuthMiddleware(
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid or expired token');
}
// Extract sovereign bank ID and identity type
// Extract claims shared by service and portal sessions
req.sovereignBankId = decoded.sovereignBankId;
req.employeeId = typeof decoded.employeeId === 'string' ? decoded.employeeId : undefined;
req.email = typeof decoded.email === 'string' ? decoded.email : undefined;
req.name = typeof decoded.name === 'string' ? decoded.name : undefined;
req.roleName = typeof decoded.roleName === 'string' ? decoded.roleName : undefined;
req.permissions = Array.isArray(decoded.permissions)
? decoded.permissions.filter((permission): permission is string => typeof permission === 'string')
: undefined;
req.sessionType = decoded.sessionType === 'portal' ? 'portal' : 'service';
req.portalSurface =
decoded.portalSurface === 'admin' ||
decoded.portalSurface === 'member' ||
decoded.portalSurface === 'core'
? decoded.portalSurface
: undefined;
req.identityType = decoded.identityType;
req.apiRole = decoded.apiRole;
if (!req.sovereignBankId) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid token payload');
}
if (isPortalSession(decoded)) {
if (!req.employeeId && !req.email) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid portal token payload');
}
} else {
if (!req.sovereignBankId) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid token payload');
}
// Verify request signature
const signatureValid = await verifyRequestSignature(
req,
req.sovereignBankId,
req.identityType || ''
);
if (!signatureValid) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature');
// Verify request signature
const signatureValid = await verifyRequestSignature(
req,
req.sovereignBankId,
req.identityType || ''
);
if (!signatureValid) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature');
}
}
// Check token expiration
@@ -207,6 +238,20 @@ export function optionalAuthMiddleware(
if (jwtSecret) {
const decoded = jwt.verify(token, jwtSecret) as JwtPayload;
req.sovereignBankId = decoded.sovereignBankId;
req.employeeId = typeof decoded.employeeId === 'string' ? decoded.employeeId : undefined;
req.email = typeof decoded.email === 'string' ? decoded.email : undefined;
req.name = typeof decoded.name === 'string' ? decoded.name : undefined;
req.roleName = typeof decoded.roleName === 'string' ? decoded.roleName : undefined;
req.permissions = Array.isArray(decoded.permissions)
? decoded.permissions.filter((permission): permission is string => typeof permission === 'string')
: undefined;
req.sessionType = decoded.sessionType === 'portal' ? 'portal' : 'service';
req.portalSurface =
decoded.portalSurface === 'admin' ||
decoded.portalSurface === 'member' ||
decoded.portalSurface === 'core'
? decoded.portalSurface
: undefined;
req.identityType = decoded.identityType;
req.apiRole = decoded.apiRole;
}
@@ -219,4 +264,3 @@ export function optionalAuthMiddleware(
}
next();
}

View File

@@ -31,6 +31,12 @@ export function createRateLimiter(tier: 'TIER_1' | 'TIER_2' | 'PRIVATE_BANK') {
});
}
const tierLimiters = {
TIER_1: createRateLimiter('TIER_1'),
TIER_2: createRateLimiter('TIER_2'),
PRIVATE_BANK: createRateLimiter('PRIVATE_BANK'),
} as const;
/**
* Dynamic rate limiter based on user role
*/
@@ -48,7 +54,7 @@ export function dynamicRateLimitMiddleware(
tier = 'TIER_2';
}
const limiter = createRateLimiter(tier);
const limiter = tierLimiters[tier];
limiter(req, res, next);
}

View File

@@ -128,3 +128,94 @@ export const iruValidationSchemas = {
id: z.string().min(1, 'ID is required'),
}),
};
export const nostroVostroValidationSchemas = {
participantCreateRequest: z.object({
participantId: z.string().min(1, 'Participant ID is required').optional(),
name: z.string().min(1, 'Institution name is required'),
bic: z
.string()
.regex(/^[A-Za-z0-9]{8}([A-Za-z0-9]{3})?$/, 'BIC must be 8 or 11 alphanumeric characters')
.optional(),
lei: z
.string()
.trim()
.min(20, 'LEI is required')
.max(20, 'LEI must be exactly 20 characters'),
country: z
.string()
.trim()
.length(2, 'Country must be a 2-letter ISO code')
.transform((value) => value.toUpperCase()),
regulatoryTier: z.enum(['SCB', 'Tier1', 'Tier2', 'PSP']),
sovereignBankId: z.string().min(1, 'Sovereign bank ID is required').optional(),
metadata: z.record(z.any()).optional(),
}),
};
export const authValidationSchemas = {
loginRequest: z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
otp: z
.string()
.trim()
.regex(/^\d{6}$/, 'OTP must be a 6-digit code')
.optional(),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
passwordChangeRequest: z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(1, 'New password is required'),
}),
passwordResetCompleteRequest: z.object({
username: z.string().min(1, 'Username is required'),
resetToken: z.string().min(1, 'Reset token is required'),
newPassword: z.string().min(1, 'New password is required'),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
passwordResetRequest: z.object({
username: z.string().min(1, 'Username is required'),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
mfaSetupEnableRequest: z.object({
secret: z.string().min(16, 'MFA secret is required'),
otp: z.string().trim().regex(/^\d{6}$/, 'OTP must be a 6-digit code'),
}),
mfaDisableRequest: z.object({
otp: z.string().trim().regex(/^\d{6}$/, 'OTP must be a 6-digit code'),
}),
issueEmployeeAccountRequest: z.object({
employeeId: z.string().min(1, 'Employee ID is required'),
employeeName: z.string().min(1, 'Employee name is required'),
email: z.string().email('Valid email is required'),
roleName: z.string().min(1, 'Role name is required'),
securityClearance: z.string().min(1, 'Security clearance is required'),
password: z.string().min(1, 'Password is required'),
mustRotatePassword: z.boolean().optional(),
}),
issueMemberAccountRequest: z.object({
memberId: z.string().min(1, 'Member ID is required'),
memberName: z.string().min(1, 'Member name is required'),
email: z.string().email('Valid email is required'),
institutionName: z.string().min(1, 'Institution name is required'),
institutionCountry: z
.string()
.trim()
.length(2, 'Institution country must be a 2-letter ISO code')
.transform((value) => value.toUpperCase()),
participantId: z.string().min(1, 'Participant ID is required').optional(),
lei: z.string().trim().length(20, 'LEI must be exactly 20 characters').optional(),
sovereignBankId: z.string().min(1, 'Sovereign bank ID is required').optional(),
password: z.string().min(1, 'Password is required'),
mustRotatePassword: z.boolean().optional(),
}),
issueResetTokenRequest: z.object({
accountType: z.enum(['employee', 'member']),
identifier: z.string().min(1, 'Identifier is required'),
}),
deactivateAccountRequest: z.object({
accountType: z.enum(['employee', 'member']),
identifier: z.string().min(1, 'Identifier is required'),
}),
};

View File

@@ -0,0 +1,310 @@
import { Router } from 'express';
import { zeroTrustAuthMiddleware, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { portalAuthService } from '../services/portal-auth.service';
import { logger } from '@/infrastructure/monitoring/logger';
import { DbisError, ErrorCode } from '@/shared/types';
import { validateRequest, authValidationSchemas } from '../middleware/validation.middleware';
import { requireAdminPermission } from '../middleware/admin-permission.middleware';
import { AdminPermission } from '@/core/admin/shared/permissions.constants';
const router = Router();
function handleAuthRouteError(error: unknown, res: any, fallbackStatus = 400) {
if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false,
error: {
code: error.code,
message: error.message,
},
timestamp: new Date(),
});
}
const message = error instanceof Error ? error.message : 'Unexpected error';
const status = /not found/i.test(message)
? 404
: /locked|expired|pending|invalid|incorrect|required|configured/i.test(message)
? 400
: fallbackStatus;
return res.status(status).json({
success: false,
error: {
code: status === 404 ? ErrorCode.NOT_FOUND : ErrorCode.VALIDATION_ERROR,
message,
},
timestamp: new Date(),
});
}
router.post(
'/login',
validateRequest({ body: authValidationSchemas.loginRequest }),
async (req, res, next) => {
try {
const { username, password, otp, surface } = req.body || {};
const result = await portalAuthService.login({
username,
password,
otp,
surface,
});
res.json(result);
} catch (error) {
if (error instanceof DbisError) {
return next(error);
}
logger.warn('Portal login rejected', {
surface: req.body?.surface || 'admin',
username: req.body?.username,
error: error instanceof Error ? error.message : String(error),
});
return res.status(401).json({
success: false,
error: {
code: ErrorCode.UNAUTHORIZED,
message: error instanceof Error ? error.message : 'Invalid credentials',
},
timestamp: new Date(),
});
}
}
);
router.get('/me', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
res.json({
user: portalAuthService.buildPortalUserFromClaims({
employeeId: req.employeeId,
email: req.email,
name: req.name,
roleName: req.roleName,
permissions: req.permissions,
sovereignBankId: req.sovereignBankId,
identityType: req.identityType || 'WEB_PORTAL',
sessionType: req.sessionType || 'portal',
portalSurface: req.portalSurface,
apiRole: req.apiRole,
}),
});
});
router.post('/logout', zeroTrustAuthMiddleware, async (_req, res) => {
res.status(204).send();
});
router.post(
'/password/change',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.passwordChangeRequest }),
async (req: AuthenticatedRequest, res) => {
try {
await portalAuthService.changePassword({
portalSurface: req.portalSurface,
employeeId: req.employeeId,
currentPassword: req.body.currentPassword,
newPassword: req.body.newPassword,
});
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/password/reset/request',
validateRequest({ body: authValidationSchemas.passwordResetRequest }),
async (req, res) => {
logger.info('Portal password reset requested', {
username: req.body.username,
surface: req.body.surface || 'admin',
});
return res.json({
success: true,
message: 'Password reset request recorded for administrator review',
});
}
);
router.post(
'/password/reset/complete',
validateRequest({ body: authValidationSchemas.passwordResetCompleteRequest }),
async (req, res) => {
try {
await portalAuthService.completePasswordReset({
username: req.body.username,
resetToken: req.body.resetToken,
newPassword: req.body.newPassword,
surface: req.body.surface,
});
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.get('/mfa/status', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
const status = await portalAuthService.getEmployeeMfaStatus(req.employeeId);
return res.json(status);
} catch (error) {
return handleAuthRouteError(error, res);
}
});
router.post('/mfa/setup', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
const enrollment = await portalAuthService.beginEmployeeMfaEnrollment(req.employeeId);
return res.json(enrollment);
} catch (error) {
return handleAuthRouteError(error, res);
}
});
router.post(
'/mfa/enable',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.mfaSetupEnableRequest }),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
await portalAuthService.enableEmployeeMfa(req.employeeId, req.body.secret, req.body.otp);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/mfa/disable',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.mfaDisableRequest }),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
await portalAuthService.disableEmployeeMfa(req.employeeId, req.body.otp);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/employee',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueEmployeeAccountRequest }),
async (req: AuthenticatedRequest, res) => {
try {
const issued = await portalAuthService.issueEmployeeAccount(req.body);
return res.status(201).json(issued);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.get(
'/admin/accounts/member',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
async (req, res) => {
try {
const memberAccounts = await portalAuthService.listMemberAccounts({
approvalStatus:
typeof req.query.approvalStatus === 'string' ? req.query.approvalStatus : undefined,
participantId:
typeof req.query.participantId === 'string' ? req.query.participantId : undefined,
});
return res.json(memberAccounts);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/member',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueMemberAccountRequest }),
async (req: AuthenticatedRequest, res) => {
try {
const issued = await portalAuthService.issueMemberAccount(req.body);
return res.status(201).json(issued);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/member/:memberId/approve',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId) {
throw new Error('Employee identity required');
}
await portalAuthService.approveMemberAccount(req.params.memberId, req.employeeId);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/password-reset/issue',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueResetTokenRequest }),
async (req, res) => {
try {
const token = await portalAuthService.issuePasswordResetToken(req.body);
return res.status(201).json(token);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/deactivate',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.deactivateAccountRequest }),
async (req, res) => {
try {
await portalAuthService.deactivateAccount(req.body);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { getEnv } from '@/shared/config/env-validator';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buffer: Buffer): string {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function base32Decode(value: string): Buffer {
const normalized = value.replace(/=+$/g, '').replace(/[\s-]/g, '').toUpperCase();
let bits = 0;
let accumulator = 0;
const bytes: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) {
throw new Error('Invalid base32 secret');
}
accumulator = (accumulator << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((accumulator >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
function hotp(secret: Buffer, counter: number, digits = 6): string {
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secret).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code = ((digest[offset] & 0x7f) << 24)
| ((digest[offset + 1] & 0xff) << 16)
| ((digest[offset + 2] & 0xff) << 8)
| (digest[offset + 3] & 0xff);
return (code % 10 ** digits).toString().padStart(digits, '0');
}
export class PortalSecurityService {
private readonly encryptionKey = crypto
.createHash('sha256')
.update(getEnv('JWT_SECRET'))
.digest();
hashPassword(password: string): string {
this.ensureStrongPassword(password);
return bcrypt.hashSync(password, 12);
}
verifyPassword(password: string, passwordHash: string): boolean {
return bcrypt.compareSync(password, passwordHash);
}
ensureStrongPassword(password: string): void {
const trimmed = password.trim();
if (trimmed.length < 14) {
throw new Error('Password must be at least 14 characters long');
}
const checks = [
/[a-z]/.test(trimmed),
/[A-Z]/.test(trimmed),
/\d/.test(trimmed),
/[^A-Za-z0-9]/.test(trimmed),
];
if (checks.some((passed) => !passed)) {
throw new Error(
'Password must include upper-case, lower-case, numeric, and special characters'
);
}
}
generateResetToken(): { plaintext: string; hash: string } {
const plaintext = crypto.randomBytes(24).toString('base64url');
return {
plaintext,
hash: this.hashResetToken(plaintext),
};
}
hashResetToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
generateTotpSecret(): string {
return base32Encode(crypto.randomBytes(20));
}
generateTotpUri(label: string, secret: string): string {
const issuer = encodeURIComponent(process.env.DBIS_PORTAL_MFA_ISSUER || 'DBIS Portal');
const account = encodeURIComponent(label);
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
}
verifyTotp(secret: string, code: string, window = 1): boolean {
const normalizedCode = code.replace(/\s+/g, '');
if (!/^\d{6}$/.test(normalizedCode)) {
return false;
}
const secretBuffer = base32Decode(secret);
const currentCounter = Math.floor(Date.now() / 1000 / 30);
for (let offset = -window; offset <= window; offset += 1) {
const candidate = hotp(secretBuffer, currentCounter + offset);
if (
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(normalizedCode))
) {
return true;
}
}
return false;
}
encryptMfaSecret(secret: string): { ciphertext: string; iv: string; tag: string } {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: ciphertext.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
};
}
decryptMfaSecret(ciphertext: string, iv: string, tag: string): string {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
Buffer.from(iv, 'base64')
);
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'base64')),
decipher.final(),
]);
return plaintext.toString('utf8');
}
}
export const portalSecurityService = new PortalSecurityService();

View File

@@ -46,17 +46,17 @@ export class HSMService {
let publicKey: string;
if (keyType === 'ECC-521') {
const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', {
const ecOptions: crypto.ECKeyPairKeyObjectOptions = {
namedCurve: 'secp521r1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
publicKey = pubKey;
};
const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', ecOptions);
publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
} else {
const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', {
const rsaOptions: crypto.RSAKeyPairKeyObjectOptions = {
modulusLength: 4096,
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
publicKey = pubKey;
};
const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', rsaOptions);
publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
}
const key: HSMKey = {
@@ -180,4 +180,3 @@ export class HSMService {
}
export const hsmService = new HSMService();

View File

@@ -100,7 +100,7 @@ const envConfig: EnvConfig = {
// IRU - Proxmox VE Configuration
PROXMOX_HOST: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE host address',
validator: (value) => {
try {
@@ -113,13 +113,13 @@ const envConfig: EnvConfig = {
errorMessage: 'PROXMOX_HOST must be a valid hostname or IP address',
},
PROXMOX_USERNAME: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE username',
validator: (value) => value.length > 0,
errorMessage: 'PROXMOX_USERNAME cannot be empty',
},
PROXMOX_PASSWORD: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE password',
validator: (value) => value.length >= 8,
errorMessage: 'PROXMOX_PASSWORD must be at least 8 characters long',
@@ -231,6 +231,128 @@ const envConfig: EnvConfig = {
},
errorMessage: 'PROMETHEUS_PUSH_GATEWAY must be a valid URL',
},
DBIS_PORTAL_SHARED_SECRET: {
required: false,
description: 'Legacy bootstrap-only employee portal secret used only when generating a portalPasswordHash',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_PORTAL_SHARED_SECRET must be at least 12 characters long',
},
DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD: {
required: false,
description: 'Bootstrap employee portal password used to generate portalPasswordHash during auth bootstrap',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD must be at least 12 characters long',
},
DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH: {
required: false,
description: 'bcrypt hash for the bootstrap employee portal password',
validator: (value) => value.startsWith('$2'),
errorMessage: 'DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH must be a bcrypt hash',
},
DBIS_MEMBER_PORTAL_SHARED_SECRET: {
required: false,
description: 'Legacy bootstrap-only member portal secret used only when generating a member portalPasswordHash',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_MEMBER_PORTAL_SHARED_SECRET must be at least 12 characters long',
},
DBIS_BOOTSTRAP_MEMBER_PASSWORD: {
required: false,
description: 'Bootstrap member portal password used to generate portalPasswordHash during member auth bootstrap',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_BOOTSTRAP_MEMBER_PASSWORD must be at least 12 characters long',
},
DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH: {
required: false,
description: 'bcrypt hash for the bootstrap member portal password',
validator: (value) => value.startsWith('$2'),
errorMessage: 'DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH must be a bcrypt hash',
},
DBIS_PORTAL_TOKEN_TTL: {
required: false,
description: 'JWT TTL for portal sessions (e.g. 8h, 30m)',
validator: (value) => /^[0-9]+[smhd]$/.test(value),
errorMessage: 'DBIS_PORTAL_TOKEN_TTL must look like 30m, 8h, or 7d',
},
DBIS_PORTAL_MAX_FAILED_ATTEMPTS: {
required: false,
description: 'Maximum failed portal login attempts before temporary lockout',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 3 && Number(value) <= 20,
errorMessage: 'DBIS_PORTAL_MAX_FAILED_ATTEMPTS must be an integer between 3 and 20',
},
DBIS_PORTAL_LOCKOUT_MINUTES: {
required: false,
description: 'Portal account lockout duration in minutes after too many failed logins',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 1440,
errorMessage: 'DBIS_PORTAL_LOCKOUT_MINUTES must be an integer between 1 and 1440',
},
DBIS_PORTAL_RESET_TOKEN_TTL_MINUTES: {
required: false,
description: 'Portal password reset token lifetime in minutes',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 5 && Number(value) <= 1440,
errorMessage: 'DBIS_PORTAL_RESET_TOKEN_TTL_MINUTES must be an integer between 5 and 1440',
},
DBIS_PORTAL_MFA_ISSUER: {
required: false,
description: 'Issuer label used in authenticator-app TOTP enrollment URIs',
validator: (value) => value.trim().length >= 2,
errorMessage: 'DBIS_PORTAL_MFA_ISSUER must be at least 2 characters long',
},
DBIS_PORTAL_EMPLOYEE_SCOPE_JSON: {
required: false,
description: 'JSON object mapping employee IDs or emails to sovereign-bank IDs',
validator: (value) => {
try {
const parsed = JSON.parse(value);
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed);
} catch {
return false;
}
},
errorMessage: 'DBIS_PORTAL_EMPLOYEE_SCOPE_JSON must be a JSON object',
},
DBIS_ENABLE_PLACEHOLDER_METRICS: {
required: false,
description: 'Allow admin dashboards to emit placeholder metrics',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ENABLE_PLACEHOLDER_METRICS must be "true" or "false"',
},
DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS: {
required: false,
description: 'Permit placeholder AS4 signing/encryption implementations for non-production use',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS must be "true" or "false"',
},
GLEIF_API_BASE_URL: {
required: false,
description: 'Base URL for the LEI registry API used during participant onboarding',
validator: (value) => {
try {
new URL(value);
return true;
} catch {
return false;
}
},
errorMessage: 'GLEIF_API_BASE_URL must be a valid URL',
},
DBIS_REQUIRE_LEI_REGISTRY_VALIDATION: {
required: false,
description: 'Require registry-backed LEI validation during institution registration',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_REQUIRE_LEI_REGISTRY_VALIDATION must be "true" or "false"',
},
DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK: {
required: false,
description: 'Allow LEI format-only acceptance when registry lookup is unavailable',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK must be "true" or "false"',
},
DBIS_LEI_LOOKUP_TIMEOUT_MS: {
required: false,
description: 'HTTP timeout for LEI registry lookups in milliseconds',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 1000,
errorMessage: 'DBIS_LEI_LOOKUP_TIMEOUT_MS must be a number greater than or equal to 1000',
},
};
/**
@@ -293,4 +415,3 @@ export function getEnv(key: string, defaultValue?: string): string {
}
return value || defaultValue || '';
}

View File

@@ -527,8 +527,15 @@ export interface RequestSignature {
}
export interface JwtPayload {
sovereignBankId: string;
sovereignBankId?: string;
identityType: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
apiRole?: string;
iat?: number;
exp?: number;
@@ -696,4 +703,3 @@ export class DbisError extends Error {
this.name = 'DbisError';
}
}

View File

@@ -6,6 +6,13 @@ declare global {
namespace Express {
interface Request {
sovereignBankId?: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
identityType?: string;
apiRole?: string;
}