feat: add member portal and auth hardening
This commit is contained in:
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
4
src/bootstrap/env.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load process environment before any module performs validation or startup side effects.
|
||||
dotenv.config();
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
259
src/core/nostro-vostro/lei-validation.service.ts
Normal file
259
src/core/nostro-vostro/lei-validation.service.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
};
|
||||
|
||||
310
src/integration/api-gateway/routes/auth.routes.ts
Normal file
310
src/integration/api-gateway/routes/auth.routes.ts
Normal 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;
|
||||
1032
src/integration/api-gateway/services/portal-auth.service.ts
Normal file
1032
src/integration/api-gateway/services/portal-auth.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
171
src/integration/api-gateway/services/portal-security.service.ts
Normal file
171
src/integration/api-gateway/services/portal-security.service.ts
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
src/types/express.d.ts
vendored
7
src/types/express.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user