diff --git a/frontend/LOGIN_AND_ENDPOINTS.md b/frontend/LOGIN_AND_ENDPOINTS.md index ef71614..a0e31ce 100644 --- a/frontend/LOGIN_AND_ENDPOINTS.md +++ b/frontend/LOGIN_AND_ENDPOINTS.md @@ -1,48 +1,53 @@ -# DBIS Admin Console - Login Credentials & Endpoints +# DBIS Portal Login Credentials & Endpoints -**Last Updated:** 2025-01-22 +**Last Updated:** 2026-04-15 --- -## 🔐 Login Credentials +## Login Credentials ### Current Authentication Status -**⚠️ Mock Authentication Active** +The portals now use the backend `/api/auth/*` endpoints. The old mock-auth behavior is no longer active. -The frontend is currently using **mock authentication** for development. This means: - -- **Any username and password will work** -- The system accepts any credentials and creates a mock admin user -- No actual backend authentication is performed yet - -### Mock User Details - -When you log in with any credentials, you'll receive: - -```json -{ - "id": "1", - "employeeId": "emp-001", - "name": "Admin User", - "email": "", - "role": "DBIS_Super_Admin", - "permissions": ["all"] -} -``` +There are now two supported login patterns: +- `core.d-bis.org` and `admin.d-bis.org` use employee-backed portal auth. +- `secure.d-bis.org` uses member-backed portal auth. ### Login Instructions -1. **Go to:** http://192.168.11.130/login -2. **Enter any username** (e.g., `admin`, `test`, `user`) -3. **Enter any password** (e.g., `password`, `123456`, `admin`) -4. **Click "Sign In"** +1. Go to the portal surface you need: + - `https://core.d-bis.org/login` + - `https://admin.d-bis.org/login` + - `https://secure.d-bis.org/login` +2. Enter the username for that surface. +3. Enter the matching secret or credential. +4. Click `Sign In`. +5. For `core` and `admin`, enter the 6-digit authenticator code when prompted if MFA is enabled on the employee account. -**Note:** The login form requires both fields to be filled, but the values don't matter - any combination will work. +### Credential Rules + +#### Employee-backed surfaces: `core` and `admin` + +The username must match an active `employee_credentials` record by employee ID or email. + +The password must match the employee's stored `portalPasswordHash` credential. + +If MFA is enabled, the login flow requires a valid TOTP code after the password step. + +#### Member surface: `secure` + +The username must match an active `portal_member_accounts` record by member ID or email. + +The password must match the member account's stored `portalPasswordHash` credential. + +The member account must also be `approved` and linked to either: +- a live participant record with GLEIF-backed LEI validation, or +- a stored institution snapshot containing a registry-validated LEI, institution name, and country. --- -## 🌐 Frontend Routes (Client-Side) +## Frontend Routes (Client-Side) ### Public Routes @@ -78,7 +83,7 @@ When you log in with any credentials, you'll receive: --- -## 🔌 Backend API Endpoints +## Backend API Endpoints ### Base URL @@ -90,9 +95,23 @@ When you log in with any credentials, you'll receive: | Method | Endpoint | Description | Status | |--------|----------|-------------|--------| -| `POST` | `/api/auth/login` | User login | ⚠️ Not implemented (using mock) | -| `POST` | `/api/auth/logout` | User logout | ⚠️ Not implemented (using mock) | -| `POST` | `/api/auth/refresh` | Refresh token | ⚠️ Not implemented | +| `POST` | `/api/auth/login` | Portal login | Live | +| `POST` | `/api/auth/logout` | Portal logout | Live | +| `GET` | `/api/auth/me` | Resolve current portal user from token | Live | +| `POST` | `/api/auth/password/change` | Authenticated password rotation | Live | +| `POST` | `/api/auth/password/reset/request` | Record password reset request | Live | +| `POST` | `/api/auth/password/reset/complete` | Complete reset with one-time token | Live | +| `GET` | `/api/auth/mfa/status` | Employee MFA status | Live | +| `POST` | `/api/auth/mfa/setup` | Generate employee MFA enrollment secret | Live | +| `POST` | `/api/auth/mfa/enable` | Enable employee MFA | Live | +| `POST` | `/api/auth/mfa/disable` | Disable employee MFA | Live | +| `POST` | `/api/auth/admin/accounts/employee` | Issue or update employee portal account | Live | +| `GET` | `/api/auth/admin/accounts/member` | List member portal accounts | Live | +| `POST` | `/api/auth/admin/accounts/member` | Issue member portal account | Live | +| `POST` | `/api/auth/admin/accounts/member/:memberId/approve` | Approve member portal account | Live | +| `POST` | `/api/auth/admin/password-reset/issue` | Issue one-time reset token | Live | +| `POST` | `/api/auth/admin/accounts/deactivate` | Deactivate employee or member account | Live | +| `POST` | `/api/auth/refresh` | Refresh token | Not implemented | ### DBIS Admin API Endpoints @@ -203,10 +222,13 @@ When you log in with any credentials, you'll receive: ### Current Implementation -- **Type:** Mock authentication (development mode) +- **Type:** Live backend-backed portal authentication - **Token Storage:** `sessionStorage` (cleared on tab close) - **Token Format:** `SOV-TOKEN ` - **Token Header:** `Authorization: SOV-TOKEN ` +- **Employee MFA:** TOTP for `core` and `admin` when enabled on the employee record +- **Lockout Policy:** Failed login attempts trigger temporary account lockout +- **Password Lifecycle:** Change-password, admin-issued reset token, and reset completion flows are available ### Request Headers @@ -230,16 +252,18 @@ Content-Type: application/json --- -## 📍 Quick Reference +## Quick Reference ### Login -- **URL:** http://192.168.11.130/login -- **Credentials:** Any username/password combination -- **After Login:** Redirects to `/dbis/overview` +- **Core:** `https://core.d-bis.org/login` +- **Admin:** `https://admin.d-bis.org/login` +- **Member:** `https://secure.d-bis.org/login` +- **After Login:** Redirects to the runtime portal home route ### Main Dashboards -- **DBIS Overview:** http://192.168.11.130/dbis/overview -- **SCB Overview:** http://192.168.11.130/scb/overview +- **Core Overview:** `https://core.d-bis.org/` +- **Admin Overview:** `https://admin.d-bis.org/` +- **Member Overview:** `https://secure.d-bis.org/` ### API Base URL - **Default:** `http://192.168.11.150:3000` @@ -247,26 +271,18 @@ Content-Type: application/json --- -## ⚠️ Important Notes +## Important Notes -1. **Mock Authentication:** Currently using mock auth - any credentials work -2. **Backend Required:** Most API endpoints require a running backend -3. **Token Format:** Uses `SOV-TOKEN` prefix (not standard `Bearer`) -4. **Session Storage:** Tokens stored in `sessionStorage` (not `localStorage`) -5. **Auto-Logout:** Session clears when browser tab closes +1. **Real portal auth:** The frontend calls the backend auth routes and no longer accepts arbitrary credentials. +2. **Backend required:** Portal login depends on the live DBIS API. +3. **Token format:** Portal sessions use JWT bearer tokens. +4. **Session storage:** Tokens and user state are kept in `sessionStorage`. +5. **Member surface:** `secure.d-bis.org` uses the member shared-secret login path. --- -## 🔄 Next Steps +## Next Steps -To enable real authentication: - -1. Implement backend `/api/auth/login` endpoint -2. Update `authService.ts` to call real API -3. Configure JWT token validation -4. Set up proper user roles and permissions -5. Remove mock authentication code - ---- - -**For development/testing:** Use any username and password to log in. +1. Replace shared-secret employee bootstrap access with individually managed credentials only. +2. Add token refresh or httpOnly cookie sessions. +3. Add role-specific operator runbooks for issuing portal accounts. diff --git a/frontend/index.html b/frontend/index.html index 2ec0998..dddb8ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,11 +4,10 @@ - DBIS Admin Console + DBIS Institutional Portal
- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d5b64af..9352475 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import ProtectedRoute from './components/auth/ProtectedRoute'; @@ -6,10 +6,12 @@ import ErrorBoundary from './components/shared/ErrorBoundary'; import PageError from './components/shared/PageError'; import LoadingSpinner from './components/shared/LoadingSpinner'; import SkipLink from './components/shared/SkipLink'; +import { runtimePortal } from './config/runtimePortal'; // Layout components (loaded immediately as they're always needed) import DBISLayout from './components/layout/DBISLayout'; import SCBLayout from './components/layout/SCBLayout'; +import MemberPortalLayout from './components/layout/MemberPortalLayout'; // Auth page (loaded immediately for faster login) import LoginPage from './pages/auth/LoginPage'; @@ -28,6 +30,11 @@ const DBISRiskCompliancePage = lazy(() => import('./pages/dbis/RiskCompliancePag const SCBOverviewPage = lazy(() => import('./pages/scb/OverviewPage')); const SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage')); const SCBCorridorPolicyPage = lazy(() => import('./pages/scb/CorridorPolicyPage')); +const MemberOverviewPage = lazy(() => import('./pages/portal/MemberOverviewPage')); +const MemberAccessPage = lazy(() => import('./pages/portal/MemberAccessPage')); +const MemberDocumentsPage = lazy(() => import('./pages/portal/MemberDocumentsPage')); +const MemberStatusPage = lazy(() => import('./pages/portal/MemberStatusPage')); +const MemberSupportPage = lazy(() => import('./pages/portal/MemberSupportPage')); /** * Lazy-loaded route wrapper with Suspense fallback @@ -39,102 +46,158 @@ const LazyRoute = ({ children }: { children: React.ReactNode }) => ( function App() { const { isAuthenticated } = useAuthStore(); + useEffect(() => { + document.title = runtimePortal.pageTitle; + }, []); + return ( - : } /> + : } /> }> - }> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - - }> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - - } /> + {runtimePortal.isMember ? ( + <> + }> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + } /> + + ) : ( + <> + }> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + }> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + } /> + + )} } /> @@ -147,4 +210,3 @@ function App() { } export default App; - diff --git a/frontend/src/components/layout/DBISLayout.tsx b/frontend/src/components/layout/DBISLayout.tsx index b242a0d..01f924f 100644 --- a/frontend/src/components/layout/DBISLayout.tsx +++ b/frontend/src/components/layout/DBISLayout.tsx @@ -33,16 +33,17 @@ const dbisNavItems = [ export default function DBISLayout() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const handleToggleSidebar = () => setSidebarCollapsed((current) => !current); return (
setSidebarCollapsed(!sidebarCollapsed)} + onToggle={handleToggleSidebar} />
- +
@@ -50,4 +51,3 @@ export default function DBISLayout() {
); } - diff --git a/frontend/src/components/layout/MemberPortalLayout.tsx b/frontend/src/components/layout/MemberPortalLayout.tsx new file mode 100644 index 0000000..35d65e8 --- /dev/null +++ b/frontend/src/components/layout/MemberPortalLayout.tsx @@ -0,0 +1,34 @@ +import { Outlet } from 'react-router-dom'; +import { useState } from 'react'; +import { MdAssignmentTurnedIn, MdDashboard, MdFolderShared, MdHealthAndSafety, MdSupportAgent } from 'react-icons/md'; +import SidebarNavigation from './SidebarNavigation'; +import TopBar from './TopBar'; + +const memberNavItems = [ + { path: '/portal/overview', label: 'Overview', icon: }, + { path: '/portal/access', label: 'Access & Requests', icon: }, + { path: '/portal/documents', label: 'Documents & Reporting', icon: }, + { path: '/portal/status', label: 'Status & Support', icon: }, + { path: '/portal/support', label: 'Support Channels', icon: }, +]; + +export default function MemberPortalLayout() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const handleToggleSidebar = () => setSidebarCollapsed((current) => !current); + + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/SCBLayout.tsx b/frontend/src/components/layout/SCBLayout.tsx index 94cd76a..4fce312 100644 --- a/frontend/src/components/layout/SCBLayout.tsx +++ b/frontend/src/components/layout/SCBLayout.tsx @@ -27,16 +27,17 @@ const scbNavItems = [ export default function SCBLayout() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const handleToggleSidebar = () => setSidebarCollapsed((current) => !current); return (
setSidebarCollapsed(!sidebarCollapsed)} + onToggle={handleToggleSidebar} />
- +
@@ -44,4 +45,3 @@ export default function SCBLayout() {
); } - diff --git a/frontend/src/components/layout/SidebarNavigation.tsx b/frontend/src/components/layout/SidebarNavigation.tsx index ac60755..29200a5 100644 --- a/frontend/src/components/layout/SidebarNavigation.tsx +++ b/frontend/src/components/layout/SidebarNavigation.tsx @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom'; import { ReactNode } from 'react'; import { clsx } from 'clsx'; import { useAuthStore } from '@/stores/authStore'; +import { runtimePortal } from '@/config/runtimePortal'; import './SidebarNavigation.css'; interface NavItem { @@ -27,9 +28,15 @@ export default function SidebarNavigation({ items, collapsed = false, onToggle } return ( ); } - diff --git a/frontend/src/components/layout/TopBar.css b/frontend/src/components/layout/TopBar.css index 75c6c4e..8c10e46 100644 --- a/frontend/src/components/layout/TopBar.css +++ b/frontend/src/components/layout/TopBar.css @@ -11,6 +11,31 @@ z-index: 50; } +.topbar__left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.topbar__menu-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-bg); + color: var(--color-text); + cursor: pointer; + font-size: 1.25rem; + flex-shrink: 0; +} + +.topbar__menu-toggle:hover { + background: var(--color-bg-tertiary, var(--color-bg-secondary)); +} + .topbar__title { font-size: 1.25rem; font-weight: 700; @@ -42,3 +67,12 @@ color: var(--color-text-secondary); } +@media (max-width: 767px) { + .topbar { + padding: 0 1rem; + } + + .topbar__user-role { + display: none; + } +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 3301ca3..ba7194c 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -2,9 +2,15 @@ import { useAuthStore } from '@/stores/authStore'; import { useNavigate } from 'react-router-dom'; import Button from '@/components/shared/Button'; +import { runtimePortal } from '@/config/runtimePortal'; import './TopBar.css'; -export default function TopBar() { +interface TopBarProps { + sidebarCollapsed?: boolean; + onToggleSidebar?: () => void; +} + +export default function TopBar({ sidebarCollapsed = false, onToggleSidebar }: TopBarProps) { const { user, logout } = useAuthStore(); const navigate = useNavigate(); @@ -16,7 +22,18 @@ export default function TopBar() { return (
-

DBIS Admin Console

+ {onToggleSidebar && sidebarCollapsed && ( + + )} +

{runtimePortal.topbarTitle}

@@ -30,4 +47,3 @@ export default function TopBar() {
); } - diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 6cdb3d2..e16473e 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -6,6 +6,19 @@ */ import { z } from 'zod'; +function resolveApiBaseUrl(): string { + const configured = import.meta.env.VITE_API_BASE_URL?.trim(); + if (configured) { + return configured; + } + + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + + return 'http://localhost:3000'; +} + const envSchema = z.object({ VITE_API_BASE_URL: z.string().url('VITE_API_BASE_URL must be a valid URL'), VITE_APP_NAME: z.string().min(1, 'VITE_APP_NAME is required'), @@ -21,7 +34,7 @@ let env: Env; try { env = envSchema.parse({ - VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000', + VITE_API_BASE_URL: resolveApiBaseUrl(), VITE_APP_NAME: import.meta.env.VITE_APP_NAME || 'DBIS Admin Console', VITE_REAL_TIME_UPDATE_INTERVAL: import.meta.env.VITE_REAL_TIME_UPDATE_INTERVAL || '5000', }); diff --git a/frontend/src/config/runtimePortal.ts b/frontend/src/config/runtimePortal.ts new file mode 100644 index 0000000..309c287 --- /dev/null +++ b/frontend/src/config/runtimePortal.ts @@ -0,0 +1,70 @@ +export type PortalSurface = 'admin' | 'member' | 'core'; + +const MEMBER_HOSTS = new Set(['secure.d-bis.org', 'members.d-bis.org']); +const CORE_HOSTS = new Set(['core.d-bis.org']); + +function getSurfaceCopy(surface: PortalSurface) { + switch (surface) { + case 'member': + return { + pageTitle: 'DBIS Member Portal', + sidebarTitle: 'DBIS Member', + topbarTitle: 'DBIS Member Portal', + loginTitle: 'DBIS Member Portal', + loginSubtitle: 'Secure institution access for submissions, requests, reporting, and member services', + homeRoute: '/portal/overview', + }; + case 'core': + return { + pageTitle: 'DBIS Core Banking Application', + sidebarTitle: 'DBIS Core', + topbarTitle: 'DBIS Core Banking', + loginTitle: 'DBIS Core Banking Application', + loginSubtitle: 'Operational access to the DBIS Core banking application and institution workflows', + homeRoute: '/dbis/overview', + }; + default: + return { + pageTitle: 'DBIS Admin Console', + sidebarTitle: 'DBIS Admin', + topbarTitle: 'DBIS Admin Console', + loginTitle: 'DBIS Admin Console', + loginSubtitle: 'Operations access for DBIS and sovereign central bank administration', + homeRoute: '/dbis/overview', + }; + } +} + +function detectSurface(): PortalSurface { + if (typeof window === 'undefined') { + return 'admin'; + } + + const host = window.location.hostname.toLowerCase(); + if (MEMBER_HOSTS.has(host)) { + return 'member'; + } + + if (CORE_HOSTS.has(host)) { + return 'core'; + } + + return 'admin'; +} + +const surface = detectSurface(); +const surfaceCopy = getSurfaceCopy(surface); + +export const runtimePortal = { + surface, + isAdmin: surface === 'admin', + isMember: surface === 'member', + isCore: surface === 'core', + host: typeof window === 'undefined' ? '' : window.location.hostname.toLowerCase(), + pageTitle: surfaceCopy.pageTitle, + sidebarTitle: surfaceCopy.sidebarTitle, + topbarTitle: surfaceCopy.topbarTitle, + loginTitle: surfaceCopy.loginTitle, + loginSubtitle: surfaceCopy.loginSubtitle, + homeRoute: surfaceCopy.homeRoute, +} as const; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7f336ff..899fd07 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { Toaster } from 'react-hot-toast'; import App from './App'; import { useAuthStore } from './stores/authStore'; import { env } from './config/env'; +import { runtimePortal } from './config/runtimePortal'; import { logger } from './utils/logger'; import { errorTracker } from './utils/errorTracking'; import './index.css'; @@ -13,7 +14,7 @@ import './index.css'; // Initialize error tracking errorTracker.init(); -logger.info('DBIS Admin Console starting', { version: env.VITE_APP_NAME }); +logger.info(`${runtimePortal.pageTitle} starting`, { version: env.VITE_APP_NAME, surface: runtimePortal.surface }); const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 5656e7a..16b1e23 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -4,13 +4,16 @@ import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/authStore'; import Button from '@/components/shared/Button'; import toast from 'react-hot-toast'; +import { runtimePortal } from '@/config/runtimePortal'; import './LoginPage.css'; export default function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [otp, setOtp] = useState(''); const [rememberMe, setRememberMe] = useState(false); const [loading, setLoading] = useState(false); + const [mfaRequired, setMfaRequired] = useState(false); const { login } = useAuthStore(); const navigate = useNavigate(); @@ -19,9 +22,18 @@ export default function LoginPage() { setLoading(true); try { - await login({ username, password, rememberMe }); - toast.success('Login successful'); - navigate('/dbis/overview'); + const result = await login({ username, password, otp, rememberMe }); + if (result.mfaRequired) { + setMfaRequired(true); + toast.success('Authenticator code required'); + } else { + if (result.passwordRotationRequired) { + toast.success('Login successful. Password rotation is recommended.'); + } else { + toast.success('Login successful'); + } + navigate(runtimePortal.homeRoute); + } } catch (error: any) { toast.error(error.message || 'Login failed'); } finally { @@ -33,8 +45,8 @@ export default function LoginPage() {
-

DBIS Admin Console

-

Sign in to your account

+

{runtimePortal.loginTitle}

+

{runtimePortal.loginSubtitle}

@@ -59,6 +71,22 @@ export default function LoginPage() { autoComplete="current-password" />
+ {mfaRequired ? ( +
+ + setOtp(e.target.value)} + required + inputMode="numeric" + autoComplete="one-time-code" + maxLength={6} + placeholder="123456" + /> +
+ ) : null}
); } - diff --git a/frontend/src/pages/dbis/OverviewPage.tsx b/frontend/src/pages/dbis/OverviewPage.tsx index c24112b..131a2ef 100644 --- a/frontend/src/pages/dbis/OverviewPage.tsx +++ b/frontend/src/pages/dbis/OverviewPage.tsx @@ -8,6 +8,7 @@ import DataTable, { Column } from '@/components/shared/DataTable'; import Button from '@/components/shared/Button'; import LoadingSpinner from '@/components/shared/LoadingSpinner'; import type { SCBStatus } from '@/types'; +import { env } from '@/config/env'; import './OverviewPage.css'; export default function OverviewPage() { @@ -38,7 +39,7 @@ export default function OverviewPage() { {isNetworkError ? (

API Connection Error

-

The backend API is not available at {import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}

+

The backend API is not available at {env.VITE_API_BASE_URL}

Please ensure the API server is running.

+ ); +} diff --git a/frontend/src/pages/portal/MemberDocumentsPage.tsx b/frontend/src/pages/portal/MemberDocumentsPage.tsx new file mode 100644 index 0000000..7c81970 --- /dev/null +++ b/frontend/src/pages/portal/MemberDocumentsPage.tsx @@ -0,0 +1,36 @@ +const documentGroups = [ + { + title: 'Governance materials', + body: 'Approved circulars, directives, standards, and member notices that require authenticated distribution or acknowledgement.', + }, + { + title: 'Reporting packs', + body: 'Structured reporting templates, filing instructions, submission windows, and evidence requirements for controlled workflows.', + }, + { + title: 'Operational records', + body: 'Access-controlled service correspondence, request responses, and downloadable artifacts associated with member actions.', + }, +]; + +export default function MemberDocumentsPage() { + return ( +
+
+

Documents & Reporting

+

+ Authenticated document delivery belongs in the secure portal when distribution, acknowledgement, or participant-level access control is required. +

+
+ +
+ {documentGroups.map((group) => ( +
+

{group.title}

+

{group.body}

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/portal/MemberOverviewPage.tsx b/frontend/src/pages/portal/MemberOverviewPage.tsx new file mode 100644 index 0000000..a91a480 --- /dev/null +++ b/frontend/src/pages/portal/MemberOverviewPage.tsx @@ -0,0 +1,58 @@ +const cards = [ + { + title: 'Submissions', + body: 'Prepare institutional filings, controlled disclosures, corridor requests, and supporting documents in one secure workspace.', + }, + { + title: 'Requests', + body: 'Track service requests, operational actions, and document retrieval across DBIS coordination teams and member support functions.', + }, + { + title: 'Reporting', + body: 'Use the secure portal for incident, ethics, sanctions, audit, and data-protection reporting workflows where disclosure channels are required.', + }, +]; + +export default function MemberOverviewPage() { + return ( +
+
+

Secure surface

+

DBIS Member Portal

+

+ Authenticated workspace for participating institutions, accredited counterparties, and approved operations teams. Use this portal for secure + submissions, controlled requests, reporting workflows, and member service coordination. +

+
+ +
+ {cards.map((card) => ( +
+

{card.title}

+

{card.body}

+
+ ))} +
+ +
+
+

What belongs here

+
    +
  • Institution onboarding packets and accredited user access management.
  • +
  • Secure document exchange for governance, compliance, and operational workflows.
  • +
  • Submission and request tracking with role-based access and auditable status history.
  • +
  • Member-facing notices, service health, and controlled release materials.
  • +
+
+ +
+

Current scope

+

+ This portal is the secure companion to the public `d-bis.org` site. Public governance and institutional reference material stay on the public + web surface; authenticated workflow activity belongs here. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/portal/MemberStatusPage.tsx b/frontend/src/pages/portal/MemberStatusPage.tsx new file mode 100644 index 0000000..2a9c290 --- /dev/null +++ b/frontend/src/pages/portal/MemberStatusPage.tsx @@ -0,0 +1,30 @@ +export default function MemberStatusPage() { + return ( +
+
+

Status & Support

+

+ Service-health notices, scheduled maintenance windows, support escalations, and secure coordination updates for member institutions. +

+
+ +
+
+

Service posture

+

+ Operational notices should summarize current availability for secure workflows, submission handling, and member support channels without exposing + internal administrative controls. +

+
+ +
+

Escalation path

+

+ Use this portal surface for authenticated support coordination, incident follow-up, and member communications that should not be handled on the + public website. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/portal/MemberSupportPage.tsx b/frontend/src/pages/portal/MemberSupportPage.tsx new file mode 100644 index 0000000..3298d08 --- /dev/null +++ b/frontend/src/pages/portal/MemberSupportPage.tsx @@ -0,0 +1,22 @@ +export default function MemberSupportPage() { + return ( +
+
+

Support Channels

+

+ Member support is routed through authenticated channels so institution identity, request history, and access controls can be preserved. +

+
+ +
+

Use secure support for

+
    +
  • Portal access and delegated user issues
  • +
  • Submission and request follow-up
  • +
  • Controlled document delivery and acknowledgement questions
  • +
  • Member service coordination that should not traverse the public site
  • +
+
+
+ ); +} diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts index d6fac36..ededfc0 100644 --- a/frontend/src/services/api/client.ts +++ b/frontend/src/services/api/client.ts @@ -82,6 +82,10 @@ class ApiClient { }, async (error: AxiosError) => { const url = error.config?.url || ''; + const isInteractiveAuthFlow = + url.startsWith('/api/auth/login') || + url.startsWith('/api/auth/password/reset') || + url.startsWith('/api/auth/mfa/'); this.cancelTokenSources.delete(url); if (axios.isCancel(error)) { @@ -101,10 +105,13 @@ class ApiClient { switch (status) { case 401: - sessionStorage.removeItem('auth_token'); - sessionStorage.removeItem('user'); - window.location.href = '/login'; - toast.error(ERROR_MESSAGES.UNAUTHORIZED); + if (!isInteractiveAuthFlow) { + sessionStorage.removeItem('auth_token'); + sessionStorage.removeItem('user'); + sessionStorage.removeItem('auth-storage'); + window.location.href = '/login'; + toast.error(ERROR_MESSAGES.UNAUTHORIZED); + } break; case 403: toast.error(ERROR_MESSAGES.FORBIDDEN); diff --git a/frontend/src/services/auth/authService.ts b/frontend/src/services/auth/authService.ts index 126c2d7..a68ecc7 100644 --- a/frontend/src/services/auth/authService.ts +++ b/frontend/src/services/auth/authService.ts @@ -1,6 +1,7 @@ // Authentication Service import { apiClient } from '../api/client'; -import type { LoginCredentials, User } from '@/types'; +import type { LoginCredentials, LoginResponse, User } from '@/types'; +import { runtimePortal } from '@/config/runtimePortal'; /** * Authentication Service @@ -19,33 +20,39 @@ class AuthService { // Tokens are cleared when the browser tab/window is closed private readonly storage = sessionStorage; - async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> { - // TODO: Replace with actual login endpoint when available - // For now, this is a placeholder that would call the backend - // const response = await apiClient.post('/api/auth/login', credentials); - - // Mock response for development - const mockUser: User = { - id: '1', - employeeId: 'emp-001', - name: 'Admin User', - email: credentials.username, - role: 'DBIS_Super_Admin', - permissions: ['all'], - }; + private decodeTokenPayload(token: string): { exp?: number } | null { + try { + return JSON.parse(atob(token.split('.')[1])); + } catch { + return null; + } + } - const mockToken = 'mock-jwt-token'; + async login(credentials: LoginCredentials): Promise { + try { + const response = await apiClient.post('/api/auth/login', { + username: credentials.username, + password: credentials.password, + ...(credentials.otp?.trim() ? { otp: credentials.otp.trim() } : {}), + surface: runtimePortal.surface, + rememberMe: credentials.rememberMe, + }); - this.setToken(mockToken); - this.setUser(mockUser); + if (!response.mfaRequired) { + this.setToken(response.token); + this.setUser(response.user); + } - return { user: mockUser, token: mockToken }; + return response; + } catch (error: any) { + const message = error?.response?.data?.error?.message || error?.message || 'Login failed'; + throw new Error(message); + } } async logout(): Promise { try { - // Call logout endpoint if available - // await apiClient.post('/api/auth/logout'); + await apiClient.post('/api/auth/logout'); } catch (error) { // Ignore errors on logout } finally { @@ -53,6 +60,17 @@ class AuthService { } } + async getCurrentUser(): Promise { + const token = this.getToken(); + if (!token || !this.isAuthenticated()) { + return null; + } + + const response = await apiClient.get<{ user: User }>('/api/auth/me'); + this.setUser(response.user); + return response.user; + } + getToken(): string | null { try { return this.storage.getItem(this.TOKEN_KEY); @@ -105,13 +123,12 @@ class AuthService { if (!token) return false; // Check if token is expired (basic check) - try { - const payload = JSON.parse(atob(token.split('.')[1])); - const exp = payload.exp * 1000; // Convert to milliseconds - return Date.now() < exp; - } catch { + const payload = this.decodeTokenPayload(token); + if (!payload?.exp) { return false; } + const exp = payload.exp * 1000; // Convert to milliseconds + return Date.now() < exp; } async refreshToken(): Promise { @@ -129,4 +146,3 @@ class AuthService { } export const authService = new AuthService(); - diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 00c01c9..49c9090 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,17 +1,17 @@ // Auth Store (Zustand) import { create } from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; import { authService } from '@/services/auth/authService'; -import type { User, LoginCredentials } from '@/types'; +import type { User, LoginCredentials, LoginResponse } from '@/types'; interface AuthState { user: User | null; token: string | null; isAuthenticated: boolean; isLoading: boolean; - login: (credentials: LoginCredentials) => Promise; + login: (credentials: LoginCredentials) => Promise; logout: () => Promise; - initialize: () => void; + initialize: () => Promise; checkPermission: (permission: string) => boolean; isDBISLevel: () => boolean; } @@ -25,18 +25,29 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: true, - initialize: () => { + initialize: async () => { const token = authService.getToken(); - const user = authService.getUser(); - if (token && user && authService.isAuthenticated()) { + if (!token || !authService.isAuthenticated()) { + authService.clearAuth(); + set({ + token: null, + user: null, + isAuthenticated: false, + isLoading: false, + }); + return; + } + + try { + const user = await authService.getCurrentUser(); set({ token, user, - isAuthenticated: true, + isAuthenticated: Boolean(user), isLoading: false, }); - } else { + } catch (error) { authService.clearAuth(); set({ token: null, @@ -50,13 +61,19 @@ export const useAuthStore = create()( login: async (credentials: LoginCredentials) => { try { set({ isLoading: true }); - const { user, token } = await authService.login(credentials); - set({ - user, - token, - isAuthenticated: true, - isLoading: false, - }); + const response = await authService.login(credentials); + if (!response.mfaRequired) { + const { user, token } = response; + set({ + user, + token, + isAuthenticated: true, + isLoading: false, + }); + } else { + set({ isLoading: false }); + } + return response; } catch (error) { set({ isLoading: false }); throw error; @@ -87,6 +104,7 @@ export const useAuthStore = create()( }), { name: 'auth-storage', + storage: createJSONStorage(() => sessionStorage), // Only persist user data, not token (token is in sessionStorage for security) partialize: (state) => ({ user: state.user, @@ -97,4 +115,3 @@ export const useAuthStore = create()( { name: 'AuthStore' } ) ); - diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 23cd067..5db3978 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -20,9 +20,32 @@ export interface AuthState { export interface LoginCredentials { username: string; password: string; + otp?: string; rememberMe?: boolean; } +export interface LoginChallengeUser { + id: string; + employeeId: string; + name: string; + email: string; + role: string; +} + +export type LoginResponse = + | { + user: User; + token: string; + mfaRequired?: false; + passwordRotationRequired?: boolean; + } + | { + mfaRequired: true; + method: 'totp'; + passwordRotationRequired?: boolean; + user: LoginChallengeUser; + }; + export interface ApiError { code: string; message: string; @@ -114,4 +137,3 @@ export interface ParticipantInfo { latency?: number; errorRate?: number; } - diff --git a/package-lock.json b/package-lock.json index 0b1e837..f96bdb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "jsonwebtoken": "^9.0.2", "kafkajs": "^2.2.4", "nodemailer": "^7.0.13", + "redis": "^4.7.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", @@ -754,7 +755,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2198,6 +2198,71 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3064,7 +3129,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3287,7 +3351,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3470,7 +3533,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3928,7 +3990,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4251,6 +4312,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4857,7 +4927,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5216,7 +5285,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5626,6 +5694,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6262,7 +6339,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8164,7 +8240,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -8362,6 +8437,23 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9411,7 +9503,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9618,7 +9709,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 83ab1a3..32d1dd3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "jsonwebtoken": "^9.0.2", "kafkajs": "^2.2.4", "nodemailer": "^7.0.13", + "redis": "^4.7.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^9.0.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0acaac0..474db72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2442,6 +2442,19 @@ model employee_credentials { employeeName String email String securityClearance String + portalPasswordHash String? + mustRotatePassword Boolean @default(false) + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + lastLoginAt DateTime? + passwordChangedAt DateTime? + passwordResetTokenHash String? + passwordResetTokenExpiresAt DateTime? + mfaEnabled Boolean @default(false) + mfaSecretCiphertext String? + mfaSecretIv String? + mfaSecretTag String? + mfaEnrolledAt DateTime? cryptographicBadgeId String? hsmCredentialId String? status String @default("active") @@ -2458,6 +2471,44 @@ model employee_credentials { @@index([status]) } +model portal_member_accounts { + id String @id + memberId String @unique + memberName String + email String @unique + institutionName String? + institutionCountry String? + participantId String? + lei String? + sovereignBankId String? + portalPasswordHash String + approvalStatus String @default("pending") + approvedAt DateTime? + approvedByEmployeeId String? + mustRotatePassword Boolean @default(false) + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + lastLoginAt DateTime? + passwordChangedAt DateTime? + passwordResetTokenHash String? + passwordResetTokenExpiresAt DateTime? + status String @default("active") + issuedAt DateTime @default(now()) + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime + + @@index([memberId]) + @@index([email]) + @@index([participantId]) + @@index([lei]) + @@index([institutionCountry]) + @@index([approvalStatus]) + @@index([status]) + @@index([sovereignBankId]) +} + model entanglement_measurements { id String @id measurementId String @unique diff --git a/scripts/deployment/bootstrap-portal-auth.cjs b/scripts/deployment/bootstrap-portal-auth.cjs new file mode 100644 index 0000000..05b83ab --- /dev/null +++ b/scripts/deployment/bootstrap-portal-auth.cjs @@ -0,0 +1,445 @@ +#!/usr/bin/env node + +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); +const { v4: uuidv4 } = require('uuid'); + +const prisma = new PrismaClient(); + +const DEFAULT_ROLES = [ + { + roleName: 'DBIS_Super_Admin', + roleDescription: 'DBIS Super Admin - Full network control', + accessLevel: 'tier_1', + permissions: ['all'], + }, + { + roleName: 'DBIS_Ops', + roleDescription: 'DBIS Operations - Network monitoring and operations', + accessLevel: 'tier_2', + permissions: ['network_operations', 'gas_qps_control', 'incident_management'], + }, + { + roleName: 'DBIS_Risk', + roleDescription: 'DBIS Risk Officer - Risk monitoring and compliance', + accessLevel: 'tier_2', + permissions: ['risk_monitoring', 'compliance_audit', 'stress_testing'], + }, + { + roleName: 'SCB_Admin', + roleDescription: 'SCB Admin - Full control over jurisdiction', + accessLevel: 'tier_3', + permissions: ['scb_administration', 'fi_management', 'corridor_management'], + }, + { + roleName: 'SCB_Risk', + roleDescription: 'SCB Risk Officer - Local risk monitoring', + accessLevel: 'tier_3', + permissions: ['local_risk_monitoring', 'compliance_review'], + }, + { + roleName: 'SCB_Tech', + roleDescription: 'SCB Tech/API Owner - Technical operations', + accessLevel: 'tier_3', + permissions: ['api_management', 'technical_operations'], + }, + { + roleName: 'SCB_Read_Only', + roleDescription: 'SCB Read-Only - View-only access', + accessLevel: 'tier_4', + permissions: ['view_only'], + }, +]; + +function getBootstrapEmployee() { + return { + employeeId: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ID || 'dbis-core-ops-001', + employeeName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_NAME || 'Core Operations User', + email: process.env.DBIS_BOOTSTRAP_EMPLOYEE_EMAIL || 'core.ops@d-bis.org', + securityClearance: process.env.DBIS_BOOTSTRAP_EMPLOYEE_CLEARANCE || 'tier_1', + roleName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ROLE || 'DBIS_Super_Admin', + }; +} + +function shouldBootstrapEmployee() { + return Boolean( + process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim() || + process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim() || + process.env.DBIS_PORTAL_SHARED_SECRET?.trim() + ); +} + +function getBootstrapMember() { + return { + memberId: process.env.DBIS_BOOTSTRAP_MEMBER_ID || 'member.test', + memberName: process.env.DBIS_BOOTSTRAP_MEMBER_NAME || 'DBIS Member Test User', + email: process.env.DBIS_BOOTSTRAP_MEMBER_EMAIL || 'member.test@members.d-bis.org', + sovereignBankId: process.env.DBIS_BOOTSTRAP_MEMBER_SOVEREIGN_BANK_ID || null, + }; +} + +function shouldBootstrapMember() { + return Boolean( + process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim() || + process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim() || + process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim() + ); +} + +function getBootstrapPasswordHash() { + const explicitHash = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim(); + if (explicitHash) { + return explicitHash; + } + + const explicitPassword = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim(); + if (explicitPassword) { + return bcrypt.hashSync(explicitPassword, 12); + } + + const legacyBootstrapSecret = process.env.DBIS_PORTAL_SHARED_SECRET?.trim(); + if (legacyBootstrapSecret) { + return bcrypt.hashSync(legacyBootstrapSecret, 12); + } + + throw new Error( + 'Set DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH or DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD before bootstrapping portal auth' + ); +} + +function getBootstrapMemberPasswordHash() { + const explicitHash = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim(); + if (explicitHash) { + return explicitHash; + } + + const explicitPassword = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim(); + if (explicitPassword) { + return bcrypt.hashSync(explicitPassword, 12); + } + + const legacyMemberSecret = process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim(); + if (legacyMemberSecret) { + return bcrypt.hashSync(legacyMemberSecret, 12); + } + + throw new Error( + 'Set DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH or DBIS_BOOTSTRAP_MEMBER_PASSWORD before bootstrapping member portal auth' + ); +} + +async function ensureSchema() { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS dbis_roles ( + id TEXT PRIMARY KEY, + "roleId" TEXT UNIQUE NOT NULL, + "roleName" TEXT NOT NULL, + "roleDescription" TEXT NOT NULL, + "accessLevel" TEXT NOT NULL, + permissions JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL + ) + `); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS dbis_roles_access_level_idx ON dbis_roles ("accessLevel")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS dbis_roles_role_id_idx ON dbis_roles ("roleId")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS dbis_roles_role_name_idx ON dbis_roles ("roleName")` + ); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS employee_credentials ( + id TEXT PRIMARY KEY, + "employeeId" TEXT UNIQUE NOT NULL, + "roleId" TEXT NOT NULL, + "employeeName" TEXT NOT NULL, + email TEXT NOT NULL, + "securityClearance" TEXT NOT NULL, + "portalPasswordHash" TEXT, + "cryptographicBadgeId" TEXT, + "hsmCredentialId" TEXT, + status TEXT NOT NULL DEFAULT 'active', + "issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "expiresAt" TIMESTAMPTZ, + "revokedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL, + CONSTRAINT employee_credentials_role_fk + FOREIGN KEY ("roleId") REFERENCES dbis_roles(id) ON DELETE CASCADE + ) + `); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS employee_credentials_employee_id_idx ON employee_credentials ("employeeId")` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "portalPasswordHash" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMPTZ` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMPTZ` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenHash" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenExpiresAt" TIMESTAMPTZ` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnabled" BOOLEAN NOT NULL DEFAULT FALSE` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretCiphertext" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretIv" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretTag" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnrolledAt" TIMESTAMPTZ` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS employee_credentials_role_id_idx ON employee_credentials ("roleId")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS employee_credentials_security_clearance_idx ON employee_credentials ("securityClearance")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS employee_credentials_status_idx ON employee_credentials (status)` + ); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS portal_member_accounts ( + id TEXT PRIMARY KEY, + "memberId" TEXT UNIQUE NOT NULL, + "memberName" TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + "institutionName" TEXT, + "institutionCountry" TEXT, + "participantId" TEXT, + lei TEXT, + "sovereignBankId" TEXT, + "portalPasswordHash" TEXT NOT NULL, + "approvalStatus" TEXT NOT NULL DEFAULT 'pending', + "approvedAt" TIMESTAMPTZ, + "approvedByEmployeeId" TEXT, + "mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE, + "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, + "lockedUntil" TIMESTAMPTZ, + "lastLoginAt" TIMESTAMPTZ, + "passwordChangedAt" TIMESTAMPTZ, + "passwordResetTokenHash" TEXT, + "passwordResetTokenExpiresAt" TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'active', + "issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "expiresAt" TIMESTAMPTZ, + "revokedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL + ) + `); + await prisma.$executeRawUnsafe( + `ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionName" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionCountry" TEXT` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_member_id_idx ON portal_member_accounts ("memberId")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_email_idx ON portal_member_accounts (email)` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_institution_country_idx ON portal_member_accounts ("institutionCountry")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_participant_id_idx ON portal_member_accounts ("participantId")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_lei_idx ON portal_member_accounts (lei)` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_approval_status_idx ON portal_member_accounts ("approvalStatus")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_status_idx ON portal_member_accounts (status)` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS portal_member_accounts_sovereign_bank_id_idx ON portal_member_accounts ("sovereignBankId")` + ); +} + +async function upsertRoles() { + for (const role of DEFAULT_ROLES) { + const existing = await prisma.dbis_roles.findFirst({ + where: { roleName: role.roleName }, + }); + + if (existing) { + await prisma.dbis_roles.update({ + where: { id: existing.id }, + data: { + roleDescription: role.roleDescription, + accessLevel: role.accessLevel, + permissions: role.permissions, + status: 'active', + updatedAt: new Date(), + }, + }); + continue; + } + + await prisma.dbis_roles.create({ + data: { + id: uuidv4(), + roleId: uuidv4(), + roleName: role.roleName, + roleDescription: role.roleDescription, + accessLevel: role.accessLevel, + permissions: role.permissions, + status: 'active', + updatedAt: new Date(), + }, + }); + } +} + +async function upsertBootstrapEmployee() { + const employee = getBootstrapEmployee(); + const portalPasswordHash = getBootstrapPasswordHash(); + const role = await prisma.dbis_roles.findFirst({ + where: { roleName: employee.roleName }, + }); + + if (!role) { + throw new Error(`Role ${employee.roleName} was not created`); + } + + await prisma.employee_credentials.upsert({ + where: { employeeId: employee.employeeId }, + update: { + roleId: role.id, + employeeName: employee.employeeName, + email: employee.email, + securityClearance: employee.securityClearance, + portalPasswordHash, + status: 'active', + revokedAt: null, + updatedAt: new Date(), + }, + create: { + id: uuidv4(), + employeeId: employee.employeeId, + roleId: role.id, + employeeName: employee.employeeName, + email: employee.email, + securityClearance: employee.securityClearance, + portalPasswordHash, + status: 'active', + updatedAt: new Date(), + }, + }); + + return employee; +} + +async function upsertBootstrapMember() { + const member = getBootstrapMember(); + const portalPasswordHash = getBootstrapMemberPasswordHash(); + + const existing = await prisma.portal_member_accounts.findFirst({ + where: { + OR: [{ memberId: member.memberId }, { email: member.email }], + }, + }); + + if (existing) { + await prisma.portal_member_accounts.update({ + where: { id: existing.id }, + data: { + memberId: member.memberId, + memberName: member.memberName, + email: member.email, + sovereignBankId: member.sovereignBankId, + portalPasswordHash, + status: 'active', + revokedAt: null, + updatedAt: new Date(), + }, + }); + return member; + } + + await prisma.portal_member_accounts.create({ + data: { + id: uuidv4(), + memberId: member.memberId, + memberName: member.memberName, + email: member.email, + sovereignBankId: member.sovereignBankId, + portalPasswordHash, + status: 'active', + updatedAt: new Date(), + }, + }); + + return member; +} + +async function main() { + await ensureSchema(); + await upsertRoles(); + const employee = shouldBootstrapEmployee() ? await upsertBootstrapEmployee() : null; + const member = shouldBootstrapMember() ? await upsertBootstrapMember() : null; + console.log( + JSON.stringify( + { + ok: true, + bootstrapEmployee: employee + ? { + employeeId: employee.employeeId, + email: employee.email, + employeeName: employee.employeeName, + roleName: employee.roleName, + } + : null, + bootstrapMember: member + ? { + memberId: member.memberId, + email: member.email, + memberName: member.memberName, + } + : null, + }, + null, + 2 + ) + ); +} + +main() + .catch(async (error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/deployment/deploy-frontend.sh b/scripts/deployment/deploy-frontend.sh index aeeb3da..ed8f863 100755 --- a/scripts/deployment/deploy-frontend.sh +++ b/scripts/deployment/deploy-frontend.sh @@ -145,7 +145,7 @@ create_frontend_container() { # Create environment file for frontend log_info "Creating frontend environment configuration..." - local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}" + local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.155}:${DBIS_API_PORT:-3000}" pct exec "$vmid" -- bash -c "cat > ${DBIS_CORE_PROJECT_ROOT:-/opt/dbis-core}/frontend/.env < { 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', () => { }); }); }); - diff --git a/src/__tests__/unit/core/nostro-vostro/lei-validation.service.test.ts b/src/__tests__/unit/core/nostro-vostro/lei-validation.service.test.ts new file mode 100644 index 0000000..d35d332 --- /dev/null +++ b/src/__tests__/unit/core/nostro-vostro/lei-validation.service.test.ts @@ -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); + }); +}); diff --git a/src/bootstrap/env.ts b/src/bootstrap/env.ts new file mode 100644 index 0000000..6cbfc0d --- /dev/null +++ b/src/bootstrap/env.ts @@ -0,0 +1,4 @@ +import dotenv from 'dotenv'; + +// Load process environment before any module performs validation or startup side effects. +dotenv.config(); diff --git a/src/core/admin/dbis-admin/dashboards/global-overview.service.ts b/src/core/admin/dbis-admin/dashboards/global-overview.service.ts index bcd2231..824bfd5 100644 --- a/src/core/admin/dbis-admin/dashboards/global-overview.service.ts +++ b/src/core/admin/dbis-admin/dashboards/global-overview.service.ts @@ -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 { - 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(); - 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(); + 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 { - // 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 { - 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 { - 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(); - diff --git a/src/core/admin/dbis-admin/dbis-admin.routes.ts b/src/core/admin/dbis-admin/dbis-admin.routes.ts index f0d6848..cd6f2fc 100644 --- a/src/core/admin/dbis-admin/dbis-admin.routes.ts +++ b/src/core/admin/dbis-admin/dbis-admin.routes.ts @@ -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; - diff --git a/src/core/admin/scb-admin/dashboards/scb-overview.service.ts b/src/core/admin/scb-admin/dashboards/scb-overview.service.ts index d2df8b0..90b4bf8 100644 --- a/src/core/admin/scb-admin/dashboards/scb-overview.service.ts +++ b/src/core/admin/scb-admin/dashboards/scb-overview.service.ts @@ -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(); - diff --git a/src/core/admin/scb-admin/scb-admin.routes.ts b/src/core/admin/scb-admin/scb-admin.routes.ts index 1f6497d..d05e388 100644 --- a/src/core/admin/scb-admin/scb-admin.routes.ts +++ b/src/core/admin/scb-admin/scb-admin.routes.ts @@ -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; - diff --git a/src/core/nostro-vostro/lei-validation.service.ts b/src/core/nostro-vostro/lei-validation.service.ts new file mode 100644 index 0000000..5592a23 --- /dev/null +++ b/src/core/nostro-vostro/lei-validation.service.ts @@ -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 { + 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 { + 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(); diff --git a/src/core/nostro-vostro/nostro-vostro.routes.ts b/src/core/nostro-vostro/nostro-vostro.routes.ts index 95dea6d..2811300 100644 --- a/src/core/nostro-vostro/nostro-vostro.routes.ts +++ b/src/core/nostro-vostro/nostro-vostro.routes.ts @@ -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; - diff --git a/src/core/nostro-vostro/nostro-vostro.service.ts b/src/core/nostro-vostro/nostro-vostro.service.ts index 78325d5..8b843f1 100644 --- a/src/core/nostro-vostro/nostro-vostro.service.ts +++ b/src/core/nostro-vostro/nostro-vostro.service.ts @@ -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 { - 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(); - diff --git a/src/core/operations/permissions/permission-types.ts b/src/core/operations/permissions/permission-types.ts index 50454fa..6372e2c 100644 --- a/src/core/operations/permissions/permission-types.ts +++ b/src/core/operations/permissions/permission-types.ts @@ -51,7 +51,7 @@ export interface ScreenAccess { } export interface ActionPermissions { - [module: string]: Action[]; + [module: string]: Array; } export interface ApprovalRequirement { @@ -164,4 +164,3 @@ export interface ResourceContext { currency?: string; metadata?: Record; } - diff --git a/src/core/operations/permissions/rbac-engine.service.ts b/src/core/operations/permissions/rbac-engine.service.ts index df6c78d..7e2ef9d 100644 --- a/src/core/operations/permissions/rbac-engine.service.ts +++ b/src/core/operations/permissions/rbac-engine.service.ts @@ -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(); - diff --git a/src/core/operations/role-management.service.ts b/src/core/operations/role-management.service.ts index 8f98c4f..3785e06 100644 --- a/src/core/operations/role-management.service.ts +++ b/src/core/operations/role-management.service.ts @@ -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 { 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 { 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(); - diff --git a/src/core/settlement/as4/as4-security.service.ts b/src/core/settlement/as4/as4-security.service.ts index e20ed46..c53d734 100644 --- a/src/core/settlement/as4/as4-security.service.ts +++ b/src/core/settlement/as4/as4-security.service.ts @@ -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'); } diff --git a/src/core/solacenet/policy/policy-engine.service.ts b/src/core/solacenet/policy/policy-engine.service.ts index d211a6c..4a15812 100644 --- a/src/core/solacenet/policy/policy-engine.service.ts +++ b/src/core/solacenet/policy/policy-engine.service.ts @@ -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; + get(key: string): Promise; + setEx(key: string, ttlSeconds: number, value: string): Promise; + keys(pattern: string): Promise; + del(keys: string | string[]): Promise; + on(event: 'error', listener: (error: Error) => void): void; +}; + +let redisClient: PolicyRedisClient | null = null; +let redisModuleChecked = false; +let redisModuleAvailable = false; +let redisConnectPromise: Promise | 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 { + 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 { - 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 { - 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 { - 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 }); diff --git a/src/index.ts b/src/index.ts index 68cb6cc..ef4754f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); }); - diff --git a/src/infrastructure/proxmox/proxmox-ve-integration.service.ts b/src/infrastructure/proxmox/proxmox-ve-integration.service.ts index b0a223a..6d8b581 100644 --- a/src/infrastructure/proxmox/proxmox-ve-integration.service.ts +++ b/src/infrastructure/proxmox/proxmox-ve-integration.service.ts @@ -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 { + 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, }; } diff --git a/src/integration/api-gateway/app.ts b/src/integration/api-gateway/app.ts index 76282a2..efa1c7b 100644 --- a/src/integration/api-gateway/app.ts +++ b/src/integration/api-gateway/app.ts @@ -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); diff --git a/src/integration/api-gateway/middleware/admin-central-auth.middleware.ts b/src/integration/api-gateway/middleware/admin-central-auth.middleware.ts index 68fc680..6941fa5 100644 --- a/src/integration/api-gateway/middleware/admin-central-auth.middleware.ts +++ b/src/integration/api-gateway/middleware/admin-central-auth.middleware.ts @@ -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(); diff --git a/src/integration/api-gateway/middleware/admin-permission.middleware.ts b/src/integration/api-gateway/middleware/admin-permission.middleware.ts index cd441cb..0fe45d4 100644 --- a/src/integration/api-gateway/middleware/admin-permission.middleware.ts +++ b/src/integration/api-gateway/middleware/admin-permission.middleware.ts @@ -13,13 +13,11 @@ import { DbisError, ErrorCode } from '@/shared/types'; export function requireAdminPermission(permission: AdminPermission) { return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { 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 => { 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; } } }; diff --git a/src/integration/api-gateway/middleware/auth.middleware.ts b/src/integration/api-gateway/middleware/auth.middleware.ts index 56bf9b0..5475499 100644 --- a/src/integration/api-gateway/middleware/auth.middleware.ts +++ b/src/integration/api-gateway/middleware/auth.middleware.ts @@ -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(); } - diff --git a/src/integration/api-gateway/middleware/rate-limit.middleware.ts b/src/integration/api-gateway/middleware/rate-limit.middleware.ts index 4b35512..84b2f17 100644 --- a/src/integration/api-gateway/middleware/rate-limit.middleware.ts +++ b/src/integration/api-gateway/middleware/rate-limit.middleware.ts @@ -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); } diff --git a/src/integration/api-gateway/middleware/validation.middleware.ts b/src/integration/api-gateway/middleware/validation.middleware.ts index ebe902e..64bb3aa 100644 --- a/src/integration/api-gateway/middleware/validation.middleware.ts +++ b/src/integration/api-gateway/middleware/validation.middleware.ts @@ -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'), + }), +}; diff --git a/src/integration/api-gateway/routes/auth.routes.ts b/src/integration/api-gateway/routes/auth.routes.ts new file mode 100644 index 0000000..baf54af --- /dev/null +++ b/src/integration/api-gateway/routes/auth.routes.ts @@ -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; diff --git a/src/integration/api-gateway/services/portal-auth.service.ts b/src/integration/api-gateway/services/portal-auth.service.ts new file mode 100644 index 0000000..1e6c353 --- /dev/null +++ b/src/integration/api-gateway/services/portal-auth.service.ts @@ -0,0 +1,1032 @@ +import jwt from 'jsonwebtoken'; +import type { SignOptions } from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import prisma from '@/shared/database/prisma'; +import { getEnv } from '@/shared/config/env-validator'; +import { JwtPayload } from '@/shared/types'; +import { adminPermissionsService } from '@/core/admin/shared/admin-permissions.service'; +import { leiValidationService } from '@/core/nostro-vostro/lei-validation.service'; +import { portalSecurityService } from './portal-security.service'; + +export interface PortalLoginRequest { + username: string; + password: string; + otp?: string; + surface?: 'admin' | 'member' | 'core'; +} + +export interface PortalUser { + id: string; + employeeId: string; + name: string; + email: string; + role: string; + sovereignBankId?: string; + permissions: string[]; +} + +export interface PortalAuthSuccessResult { + user: PortalUser; + token: string; + mfaRequired?: false; + passwordRotationRequired?: boolean; +} + +export interface PortalAuthChallengeResult { + mfaRequired: true; + method: 'totp'; + passwordRotationRequired?: boolean; + user: Pick; +} + +export type PortalAuthResult = PortalAuthSuccessResult | PortalAuthChallengeResult; + +export interface PasswordResetTokenResult { + accountType: 'employee' | 'member'; + identifier: string; + resetToken: string; + expiresAt: Date; +} + +interface IssueEmployeeAccountRequest { + employeeId: string; + employeeName: string; + email: string; + roleName: string; + securityClearance: string; + password: string; + mustRotatePassword?: boolean; +} + +interface IssueMemberAccountRequest { + memberId: string; + memberName: string; + email: string; + institutionName: string; + institutionCountry: string; + participantId?: string; + lei?: string; + sovereignBankId?: string; + password: string; + mustRotatePassword?: boolean; +} + +type ScopeMap = Record; + +function parseScopeMap(): ScopeMap { + const raw = process.env.DBIS_PORTAL_EMPLOYEE_SCOPE_JSON?.trim(); + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ); + } catch { + return {}; + } +} + +function isLocked(lockedUntil?: Date | null): boolean { + return Boolean(lockedUntil && lockedUntil.getTime() > Date.now()); +} + +function formatLockMessage(lockedUntil?: Date | null): string { + if (!lockedUntil) { + return 'Account is locked'; + } + return `Account is locked until ${lockedUntil.toISOString()}`; +} + +function readLeiValidation(metadata: unknown): { registryValidated?: boolean; source?: string } | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return null; + } + + const candidate = (metadata as Record).leiValidation; + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + return null; + } + + return candidate as { registryValidated?: boolean; source?: string }; +} + +export class PortalAuthService { + private readonly maxFailedAttempts = Number(process.env.DBIS_PORTAL_MAX_FAILED_ATTEMPTS || '5'); + private readonly lockoutMinutes = Number(process.env.DBIS_PORTAL_LOCKOUT_MINUTES || '30'); + private readonly resetTokenTtlMinutes = Number(process.env.DBIS_PORTAL_RESET_TOKEN_TTL_MINUTES || '30'); + + async login(request: PortalLoginRequest): Promise { + const surface = request.surface ?? 'admin'; + + if (surface === 'member') { + return this.loginMemberSurface(request); + } + + return this.loginEmployeeSurface(request, surface); + } + + buildPortalUserFromClaims(payload: JwtPayload): PortalUser { + return { + id: payload.employeeId || payload.email || 'portal-user', + employeeId: payload.employeeId || payload.email || 'portal-user', + name: payload.name || 'Portal User', + email: payload.email || '', + role: payload.roleName || payload.apiRole || 'Unknown', + sovereignBankId: payload.sovereignBankId, + permissions: Array.isArray(payload.permissions) ? payload.permissions : [], + }; + } + + async changePassword(params: { + portalSurface?: 'admin' | 'member' | 'core'; + employeeId?: string; + currentPassword: string; + newPassword: string; + }): Promise { + if (params.portalSurface === 'member') { + const member = await prisma.portal_member_accounts.findFirst({ + where: { + memberId: params.employeeId, + status: 'active', + }, + }); + + if (!member) { + throw new Error('Member account not found'); + } + + if (!portalSecurityService.verifyPassword(params.currentPassword, member.portalPasswordHash)) { + throw new Error('Current password is incorrect'); + } + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + portalPasswordHash: portalSecurityService.hashPassword(params.newPassword), + mustRotatePassword: false, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + updatedAt: new Date(), + }, + }); + return; + } + + const employee = await prisma.employee_credentials.findFirst({ + where: { + employeeId: params.employeeId, + status: 'active', + }, + }); + + if (!employee) { + throw new Error('Employee account not found'); + } + + if (!employee.portalPasswordHash) { + throw new Error('Portal credential is not configured for this employee'); + } + + if (!portalSecurityService.verifyPassword(params.currentPassword, employee.portalPasswordHash)) { + throw new Error('Current password is incorrect'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + portalPasswordHash: portalSecurityService.hashPassword(params.newPassword), + mustRotatePassword: false, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + updatedAt: new Date(), + }, + }); + } + + async issuePasswordResetToken(params: { + accountType: 'employee' | 'member'; + identifier: string; + }): Promise { + const token = portalSecurityService.generateResetToken(); + const expiresAt = new Date(Date.now() + this.resetTokenTtlMinutes * 60 * 1000); + + if (params.accountType === 'member') { + const member = await prisma.portal_member_accounts.findFirst({ + where: { + OR: [ + { memberId: params.identifier }, + { email: { equals: params.identifier, mode: 'insensitive' } }, + ], + }, + }); + + if (!member) { + throw new Error('Member account not found'); + } + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + passwordResetTokenHash: token.hash, + passwordResetTokenExpiresAt: expiresAt, + failedLoginAttempts: 0, + lockedUntil: null, + updatedAt: new Date(), + }, + }); + + return { + accountType: 'member', + identifier: member.memberId, + resetToken: token.plaintext, + expiresAt, + }; + } + + const employee = await prisma.employee_credentials.findFirst({ + where: { + OR: [ + { employeeId: params.identifier }, + { email: { equals: params.identifier, mode: 'insensitive' } }, + ], + }, + }); + + if (!employee) { + throw new Error('Employee account not found'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + passwordResetTokenHash: token.hash, + passwordResetTokenExpiresAt: expiresAt, + failedLoginAttempts: 0, + lockedUntil: null, + updatedAt: new Date(), + }, + }); + + return { + accountType: 'employee', + identifier: employee.employeeId, + resetToken: token.plaintext, + expiresAt, + }; + } + + async completePasswordReset(params: { + surface?: 'admin' | 'member' | 'core'; + username: string; + resetToken: string; + newPassword: string; + }): Promise { + const resetTokenHash = portalSecurityService.hashResetToken(params.resetToken); + + if (params.surface === 'member') { + const member = await prisma.portal_member_accounts.findFirst({ + where: { + OR: [ + { memberId: params.username.trim() }, + { email: { equals: params.username.trim(), mode: 'insensitive' } }, + ], + }, + }); + + if ( + !member || + member.passwordResetTokenHash !== resetTokenHash || + !member.passwordResetTokenExpiresAt || + member.passwordResetTokenExpiresAt.getTime() < Date.now() + ) { + throw new Error('Invalid or expired password reset token'); + } + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + portalPasswordHash: portalSecurityService.hashPassword(params.newPassword), + mustRotatePassword: false, + failedLoginAttempts: 0, + lockedUntil: null, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + updatedAt: new Date(), + }, + }); + return; + } + + const employee = await prisma.employee_credentials.findFirst({ + where: { + OR: [ + { employeeId: params.username.trim() }, + { email: { equals: params.username.trim(), mode: 'insensitive' } }, + ], + }, + }); + + if ( + !employee || + employee.passwordResetTokenHash !== resetTokenHash || + !employee.passwordResetTokenExpiresAt || + employee.passwordResetTokenExpiresAt.getTime() < Date.now() + ) { + throw new Error('Invalid or expired password reset token'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + portalPasswordHash: portalSecurityService.hashPassword(params.newPassword), + mustRotatePassword: false, + failedLoginAttempts: 0, + lockedUntil: null, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + updatedAt: new Date(), + }, + }); + } + + async getEmployeeMfaStatus(employeeId: string): Promise<{ enabled: boolean; enrolledAt?: Date | null }> { + const employee = await prisma.employee_credentials.findUnique({ + where: { employeeId }, + select: { mfaEnabled: true, mfaEnrolledAt: true }, + }); + + if (!employee) { + throw new Error('Employee account not found'); + } + + return { + enabled: employee.mfaEnabled, + enrolledAt: employee.mfaEnrolledAt, + }; + } + + async beginEmployeeMfaEnrollment(employeeId: string): Promise<{ secret: string; otpauthUrl: string }> { + const employee = await prisma.employee_credentials.findUnique({ + where: { employeeId }, + select: { employeeId: true, email: true, status: true }, + }); + + if (!employee || employee.status !== 'active') { + throw new Error('Employee account not found'); + } + + const secret = portalSecurityService.generateTotpSecret(); + return { + secret, + otpauthUrl: portalSecurityService.generateTotpUri(employee.email || employee.employeeId, secret), + }; + } + + async enableEmployeeMfa(employeeId: string, secret: string, otp: string): Promise { + if (!portalSecurityService.verifyTotp(secret, otp)) { + throw new Error('Invalid MFA verification code'); + } + + const encrypted = portalSecurityService.encryptMfaSecret(secret); + await prisma.employee_credentials.update({ + where: { employeeId }, + data: { + mfaEnabled: true, + mfaSecretCiphertext: encrypted.ciphertext, + mfaSecretIv: encrypted.iv, + mfaSecretTag: encrypted.tag, + mfaEnrolledAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + async disableEmployeeMfa(employeeId: string, otp: string): Promise { + const employee = await prisma.employee_credentials.findUnique({ + where: { employeeId }, + select: { + id: true, + mfaEnabled: true, + mfaSecretCiphertext: true, + mfaSecretIv: true, + mfaSecretTag: true, + }, + }); + + if (!employee || !employee.mfaEnabled) { + throw new Error('MFA is not enabled for this account'); + } + + const secret = this.readEmployeeMfaSecret(employee); + if (!portalSecurityService.verifyTotp(secret, otp)) { + throw new Error('Invalid MFA verification code'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + mfaEnabled: false, + mfaSecretCiphertext: null, + mfaSecretIv: null, + mfaSecretTag: null, + mfaEnrolledAt: null, + updatedAt: new Date(), + }, + }); + } + + async issueEmployeeAccount( + request: IssueEmployeeAccountRequest + ): Promise<{ employeeId: string; email: string; roleName: string }> { + const role = await prisma.dbis_roles.findFirst({ + where: { roleName: request.roleName }, + }); + + if (!role) { + throw new Error(`Role ${request.roleName} does not exist`); + } + + const passwordHash = portalSecurityService.hashPassword(request.password); + const existing = await prisma.employee_credentials.findFirst({ + where: { + OR: [ + { employeeId: request.employeeId }, + { email: { equals: request.email, mode: 'insensitive' } }, + ], + }, + }); + + if (existing) { + await prisma.employee_credentials.update({ + where: { id: existing.id }, + data: { + employeeId: request.employeeId, + employeeName: request.employeeName, + email: request.email, + roleId: role.id, + securityClearance: request.securityClearance, + portalPasswordHash: passwordHash, + mustRotatePassword: Boolean(request.mustRotatePassword), + status: 'active', + revokedAt: null, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + failedLoginAttempts: 0, + lockedUntil: null, + updatedAt: new Date(), + }, + }); + } else { + await prisma.employee_credentials.create({ + data: { + id: uuidv4(), + employeeId: request.employeeId, + employeeName: request.employeeName, + email: request.email, + roleId: role.id, + securityClearance: request.securityClearance, + portalPasswordHash: passwordHash, + mustRotatePassword: Boolean(request.mustRotatePassword), + status: 'active', + passwordChangedAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + return { + employeeId: request.employeeId, + email: request.email, + roleName: request.roleName, + }; + } + + async issueMemberAccount( + request: IssueMemberAccountRequest, + approvedByEmployeeId?: string + ): Promise<{ + memberId: string; + email: string; + participantId: string; + approvalStatus: string; + }> { + const institution = await this.resolveMemberInstitution(request); + + const passwordHash = portalSecurityService.hashPassword(request.password); + const existing = await prisma.portal_member_accounts.findFirst({ + where: { + OR: [ + { memberId: request.memberId }, + { email: { equals: request.email, mode: 'insensitive' } }, + ], + }, + }); + + const approvalStatus = approvedByEmployeeId ? 'approved' : 'pending'; + const approvedAt = approvedByEmployeeId ? new Date() : null; + + if (existing) { + await prisma.portal_member_accounts.update({ + where: { id: existing.id }, + data: { + memberId: request.memberId, + memberName: request.memberName, + email: request.email, + institutionName: institution.institutionName, + institutionCountry: institution.institutionCountry, + participantId: institution.participantId, + lei: institution.lei, + sovereignBankId: institution.sovereignBankId, + portalPasswordHash: passwordHash, + approvalStatus, + approvedAt, + approvedByEmployeeId: approvedByEmployeeId || null, + mustRotatePassword: Boolean(request.mustRotatePassword), + status: 'active', + revokedAt: null, + failedLoginAttempts: 0, + lockedUntil: null, + passwordChangedAt: new Date(), + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + updatedAt: new Date(), + }, + }); + } else { + await prisma.portal_member_accounts.create({ + data: { + id: uuidv4(), + memberId: request.memberId, + memberName: request.memberName, + email: request.email, + institutionName: institution.institutionName, + institutionCountry: institution.institutionCountry, + participantId: institution.participantId, + lei: institution.lei, + sovereignBankId: institution.sovereignBankId, + portalPasswordHash: passwordHash, + approvalStatus, + approvedAt, + approvedByEmployeeId: approvedByEmployeeId || null, + mustRotatePassword: Boolean(request.mustRotatePassword), + status: 'active', + passwordChangedAt: new Date(), + updatedAt: new Date(), + }, + }); + } + + return { + memberId: request.memberId, + email: request.email, + participantId: institution.participantId || 'snapshot-only', + approvalStatus, + }; + } + + async approveMemberAccount(memberId: string, approvedByEmployeeId: string): Promise { + const member = await prisma.portal_member_accounts.findUnique({ + where: { memberId }, + }); + + if (!member) { + throw new Error('Member account not found'); + } + + const institution = await this.resolveStoredMemberInstitution(member); + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + approvalStatus: 'approved', + approvedAt: new Date(), + approvedByEmployeeId, + institutionName: institution.institutionName, + institutionCountry: institution.institutionCountry, + participantId: institution.participantId, + lei: institution.lei, + sovereignBankId: institution.sovereignBankId, + updatedAt: new Date(), + }, + }); + } + + async listMemberAccounts(filters?: { + approvalStatus?: string; + participantId?: string; + }) { + return prisma.portal_member_accounts.findMany({ + where: { + ...(filters?.approvalStatus ? { approvalStatus: filters.approvalStatus } : {}), + ...(filters?.participantId ? { participantId: filters.participantId } : {}), + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async deactivateAccount(params: { + accountType: 'employee' | 'member'; + identifier: string; + }): Promise { + if (params.accountType === 'member') { + const member = await prisma.portal_member_accounts.findFirst({ + where: { + OR: [{ memberId: params.identifier }, { email: { equals: params.identifier, mode: 'insensitive' } }], + }, + }); + if (!member) { + throw new Error('Member account not found'); + } + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + status: 'inactive', + revokedAt: new Date(), + lockedUntil: null, + updatedAt: new Date(), + }, + }); + return; + } + + const employee = await prisma.employee_credentials.findFirst({ + where: { + OR: [{ employeeId: params.identifier }, { email: { equals: params.identifier, mode: 'insensitive' } }], + }, + }); + if (!employee) { + throw new Error('Employee account not found'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + status: 'inactive', + revokedAt: new Date(), + lockedUntil: null, + updatedAt: new Date(), + }, + }); + } + + private async loginEmployeeSurface( + request: PortalLoginRequest, + surface: 'admin' | 'core' + ): Promise { + const username = request.username.trim(); + const password = request.password; + const employee = await prisma.employee_credentials.findFirst({ + where: { + status: 'active', + OR: [ + { employeeId: username }, + { email: { equals: username, mode: 'insensitive' } }, + ], + }, + include: { dbis_roles: true }, + }); + + if (!employee) { + throw new Error('Invalid credentials'); + } + + if (employee.expiresAt && employee.expiresAt < new Date()) { + throw new Error('Credential expired'); + } + + if (isLocked(employee.lockedUntil)) { + throw new Error(formatLockMessage(employee.lockedUntil)); + } + + if (!employee.portalPasswordHash) { + throw new Error('Portal credential is not configured for this employee'); + } + + const matchesPassword = portalSecurityService.verifyPassword(password, employee.portalPasswordHash); + if (!matchesPassword) { + await this.recordFailedEmployeeLogin(employee.id, employee.failedLoginAttempts); + throw new Error('Invalid credentials'); + } + + if (employee.mfaEnabled) { + const secret = this.readEmployeeMfaSecret(employee); + if (!request.otp) { + return { + mfaRequired: true, + method: 'totp', + passwordRotationRequired: employee.mustRotatePassword, + user: { + id: employee.employeeId, + employeeId: employee.employeeId, + name: employee.employeeName, + email: employee.email, + role: employee.dbis_roles?.roleName || 'Unknown', + }, + }; + } + + if (!portalSecurityService.verifyTotp(secret, request.otp)) { + throw new Error('Invalid MFA verification code'); + } + } + + const permissions = (await adminPermissionsService.getEmployeePermissions(employee.employeeId)) + .map((permission) => permission as string); + const sovereignBankId = this.resolveSovereignBankScope(employee.employeeId, employee.email); + const roleName = employee.dbis_roles?.roleName || 'Unknown'; + + if (roleName.startsWith('SCB_') && !sovereignBankId) { + throw new Error('No sovereign-bank scope configured for this employee'); + } + + await prisma.employee_credentials.update({ + where: { id: employee.id }, + data: { + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: new Date(), + updatedAt: new Date(), + }, + }); + + const user: PortalUser = { + id: employee.employeeId, + employeeId: employee.employeeId, + name: employee.employeeName, + email: employee.email, + role: roleName, + sovereignBankId: sovereignBankId || undefined, + permissions, + }; + + return { + user, + token: this.signPortalToken({ + employeeId: employee.employeeId, + email: employee.email, + name: employee.employeeName, + roleName, + permissions, + sovereignBankId: sovereignBankId || undefined, + identityType: 'WEB_PORTAL', + sessionType: 'portal', + portalSurface: surface, + apiRole: roleName, + }), + passwordRotationRequired: employee.mustRotatePassword, + }; + } + + private async loginMemberSurface( + request: PortalLoginRequest + ): Promise { + const username = request.username.trim(); + const member = await prisma.portal_member_accounts.findFirst({ + where: { + status: 'active', + OR: [ + { memberId: username }, + { email: { equals: username, mode: 'insensitive' } }, + ], + }, + }); + + if (!member) { + throw new Error('Invalid credentials'); + } + + if (member.expiresAt && member.expiresAt < new Date()) { + throw new Error('Credential expired'); + } + + if (isLocked(member.lockedUntil)) { + throw new Error(formatLockMessage(member.lockedUntil)); + } + + if (member.approvalStatus !== 'approved') { + throw new Error('Member account is pending institution approval'); + } + + if (!member.lei || !member.institutionName || !member.institutionCountry) { + throw new Error('Member account is missing institution approval metadata'); + } + + const institution = await this.resolveStoredMemberInstitution(member); + + const matchesPassword = portalSecurityService.verifyPassword(request.password, member.portalPasswordHash); + if (!matchesPassword) { + await this.recordFailedMemberLogin(member.id, member.failedLoginAttempts); + throw new Error('Invalid credentials'); + } + + await prisma.portal_member_accounts.update({ + where: { id: member.id }, + data: { + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: new Date(), + institutionName: institution.institutionName, + institutionCountry: institution.institutionCountry, + participantId: institution.participantId, + lei: institution.lei, + sovereignBankId: institution.sovereignBankId, + updatedAt: new Date(), + }, + }); + + const user: PortalUser = { + id: member.memberId, + employeeId: member.memberId, + name: member.memberName, + email: member.email, + role: 'DBIS_Member_User', + sovereignBankId: institution.sovereignBankId || undefined, + permissions: ['portal:access'], + }; + + return { + user, + token: this.signPortalToken({ + employeeId: user.employeeId, + email: user.email, + name: user.name, + roleName: user.role, + permissions: user.permissions, + sovereignBankId: institution.sovereignBankId || undefined, + identityType: 'WEB_PORTAL', + sessionType: 'portal', + portalSurface: 'member', + apiRole: 'member', + }), + passwordRotationRequired: member.mustRotatePassword, + }; + } + + private readEmployeeMfaSecret(employee: { + mfaSecretCiphertext?: string | null; + mfaSecretIv?: string | null; + mfaSecretTag?: string | null; + }): string { + if (!employee.mfaSecretCiphertext || !employee.mfaSecretIv || !employee.mfaSecretTag) { + throw new Error('MFA secret is not configured for this account'); + } + + return portalSecurityService.decryptMfaSecret( + employee.mfaSecretCiphertext, + employee.mfaSecretIv, + employee.mfaSecretTag + ); + } + + private async recordFailedEmployeeLogin(employeeRecordId: string, currentAttempts: number): Promise { + const nextAttempts = currentAttempts + 1; + const shouldLock = nextAttempts >= this.maxFailedAttempts; + await prisma.employee_credentials.update({ + where: { id: employeeRecordId }, + data: { + failedLoginAttempts: nextAttempts, + lockedUntil: shouldLock ? new Date(Date.now() + this.lockoutMinutes * 60 * 1000) : null, + updatedAt: new Date(), + }, + }); + } + + private async recordFailedMemberLogin(memberRecordId: string, currentAttempts: number): Promise { + const nextAttempts = currentAttempts + 1; + const shouldLock = nextAttempts >= this.maxFailedAttempts; + await prisma.portal_member_accounts.update({ + where: { id: memberRecordId }, + data: { + failedLoginAttempts: nextAttempts, + lockedUntil: shouldLock ? new Date(Date.now() + this.lockoutMinutes * 60 * 1000) : null, + updatedAt: new Date(), + }, + }); + } + + private resolveSovereignBankScope(employeeId: string, email: string): string | null { + const scopeMap = parseScopeMap(); + return scopeMap[employeeId] || scopeMap[email.toLowerCase()] || null; + } + + private async resolveMemberInstitution(request: IssueMemberAccountRequest): Promise<{ + institutionName: string; + institutionCountry: string; + participantId: string | null; + lei: string; + sovereignBankId: string | null; + }> { + if (request.participantId) { + const participant = await prisma.nostro_vostro_participants.findUnique({ + where: { participantId: request.participantId }, + }).catch(() => null); + + if (participant?.status === 'active') { + const leiValidation = readLeiValidation(participant.metadata); + if (participant.lei && leiValidation?.registryValidated && leiValidation.source === 'gleif') { + return { + institutionName: participant.name, + institutionCountry: participant.country, + participantId: participant.participantId, + lei: participant.lei, + sovereignBankId: participant.sovereignBankId || null, + }; + } + } + } + + const validation = await leiValidationService.validateRegistrationCandidate({ + lei: request.lei, + institutionName: request.institutionName, + country: request.institutionCountry, + }); + + return { + institutionName: request.institutionName, + institutionCountry: request.institutionCountry.toUpperCase(), + participantId: request.participantId || null, + lei: validation.normalizedLei, + sovereignBankId: request.sovereignBankId || null, + }; + } + + private async resolveStoredMemberInstitution(member: { + participantId?: string | null; + institutionName?: string | null; + institutionCountry?: string | null; + lei?: string | null; + sovereignBankId?: string | null; + }): Promise<{ + institutionName: string; + institutionCountry: string; + participantId: string | null; + lei: string; + sovereignBankId: string | null; + }> { + if (member.participantId) { + const participant = await prisma.nostro_vostro_participants.findUnique({ + where: { participantId: member.participantId }, + }).catch(() => null); + + if (participant?.status === 'active') { + const leiValidation = readLeiValidation(participant.metadata); + if (participant.lei && leiValidation?.registryValidated && leiValidation.source === 'gleif') { + return { + institutionName: participant.name, + institutionCountry: participant.country, + participantId: participant.participantId, + lei: participant.lei, + sovereignBankId: participant.sovereignBankId || null, + }; + } + } + } + + if (!member.institutionName || !member.institutionCountry || !member.lei) { + throw new Error('Member institution record is incomplete'); + } + + const validation = await leiValidationService.validateRegistrationCandidate({ + lei: member.lei, + institutionName: member.institutionName, + country: member.institutionCountry, + }); + + return { + institutionName: member.institutionName, + institutionCountry: member.institutionCountry.toUpperCase(), + participantId: member.participantId || null, + lei: validation.normalizedLei, + sovereignBankId: member.sovereignBankId || null, + }; + } + + private signPortalToken(claims: JwtPayload): string { + const jwtSecret = getEnv('JWT_SECRET'); + const signOptions: SignOptions = { + expiresIn: (process.env.DBIS_PORTAL_TOKEN_TTL || '8h') as SignOptions['expiresIn'], + }; + return jwt.sign( + { + ...claims, + permissions: claims.permissions || [], + }, + jwtSecret, + signOptions + ); + } +} + +export const portalAuthService = new PortalAuthService(); diff --git a/src/integration/api-gateway/services/portal-security.service.ts b/src/integration/api-gateway/services/portal-security.service.ts new file mode 100644 index 0000000..958456a --- /dev/null +++ b/src/integration/api-gateway/services/portal-security.service.ts @@ -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(); diff --git a/src/integration/hsm/hsm.service.ts b/src/integration/hsm/hsm.service.ts index 91aa2bb..8ea060e 100644 --- a/src/integration/hsm/hsm.service.ts +++ b/src/integration/hsm/hsm.service.ts @@ -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(); - diff --git a/src/shared/config/env-validator.ts b/src/shared/config/env-validator.ts index 176ed1e..dab7d4f 100644 --- a/src/shared/config/env-validator.ts +++ b/src/shared/config/env-validator.ts @@ -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 || ''; } - diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 27ccd02..fd70038 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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'; } } - diff --git a/src/types/express.d.ts b/src/types/express.d.ts index fd195f0..af08c68 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -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; } diff --git a/templates/nginx/dbis-frontend.conf b/templates/nginx/dbis-frontend.conf index 490cf94..090a2b2 100644 --- a/templates/nginx/dbis-frontend.conf +++ b/templates/nginx/dbis-frontend.conf @@ -15,6 +15,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always; # SPA routing location / { @@ -23,7 +24,7 @@ server { # API proxy (optional - if frontend needs to proxy API requests) location /api { - proxy_pass http://192.168.11.150:3000; + proxy_pass http://192.168.11.155:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -46,4 +47,3 @@ server { add_header Content-Type text/plain; } } -