diff --git a/config/deployment-omnl.production.env.example b/config/deployment-omnl.production.env.example index d5726e9..72822e6 100644 --- a/config/deployment-omnl.production.env.example +++ b/config/deployment-omnl.production.env.example @@ -20,7 +20,20 @@ OMNL_ALLOW_CHAIN_MINT_EXECUTE=1 # === Auth === OMNL_API_KEY= -VITE_OMNL_API_KEY= +# Do NOT set VITE_OMNL_API_KEY in production (nginx injects server-side) + +# === Four super-admin portal keys (one per LXC 5825–5828) === +OMNL_SUPER_ADMIN_OFFICE24_KEY= +OMNL_SUPER_ADMIN_EXCHANGE_KEY= +OMNL_SUPER_ADMIN_SECURE_KEY= +OMNL_SUPER_ADMIN_FOREX_KEY= +OMNL_PORTAL_INTERNAL_SECRET= + +# === Customer API keys (read + trade scopes only; comma-separated key:scope) === +OMNL_CUSTOMER_API_KEYS= +OMNL_CUSTOMER_API_KEY_1= +OMNL_CUSTOMER_SECURITY_PRODUCTION=1 +JWT_SECRET= # === On-chain M2 mint / transfer (Chain 138 operator) === OMNL_MINT_OPERATOR_PRIVATE_KEY= diff --git a/config/omnl-customer-security.production.v1.json b/config/omnl-customer-security.production.v1.json new file mode 100644 index 0000000..b356c5b --- /dev/null +++ b/config/omnl-customer-security.production.v1.json @@ -0,0 +1,30 @@ +{ + "version": "1.0.0", + "production": true, + "neverExposeOperatorKeyInBrowser": true, + "portalInjectAuthViaNginx": true, + "requireApiKeyAllExchangeRoutes": true, + "customerAllowedScopes": ["read", "trade"], + "superAdminOnlyScopes": ["settle", "token_load", "transfer", "admin"], + "rateLimits": { + "customerPerMinute": 30, + "superAdminPerMinute": 120, + "anonymousPerMinute": 0 + }, + "corsOrigins": [ + "https://secure.d-bis.org", + "https://online.omdnl.org", + "https://office24.omdnl.org", + "https://digital.omdnl.org", + "https://exchange.d-bis.org", + "https://exchange.omdnl.org", + "https://forex.omdnl.org" + ], + "publicEndpoints": [ + "/health", + "/health/ledger", + "/money-supply/public", + "/public/money-supply" + ], + "notes": "Customer keys: OMNL_CUSTOMER_API_KEYS=key:read|trade or OMNL_CUSTOMER_API_KEY_1..3. Super-admin keys: OMNL_SUPER_ADMIN_*_KEY in config/omnl-super-admins.v1.json. Portal browsers use nginx-injected auth — never VITE_OMNL_API_KEY." +} diff --git a/config/omnl-fineract-roles.v1.json b/config/omnl-fineract-roles.v1.json new file mode 100644 index 0000000..19e7bdd --- /dev/null +++ b/config/omnl-fineract-roles.v1.json @@ -0,0 +1,40 @@ +{ + "version": "1.0.0", + "description": "Fineract / OMNL separation-of-duties matrix — four super admins, three Fineract service accounts", + "tenant": "omnl", + "officeId": 24, + "superAdmins": [ + { "id": "sa-office24", "fineractUser": "ali_hospitallers_admin", "portalCtid": 5825 }, + { "id": "sa-exchange", "fineractUser": "ali_hospitallers_operator", "portalCtid": 5826 }, + { "id": "sa-secure", "fineractUser": "ali_hospitallers_admin", "portalCtid": 5827 }, + { "id": "sa-forex", "fineractUser": "ali_hospitallers_operator", "portalCtid": 5828 } + ], + "fineractUsers": [ + { + "username": "ali_hospitallers_tenant", + "role": "tenant_api", + "permissions": ["journal_read", "journal_post", "money_supply_read"] + }, + { + "username": "ali_hospitallers_admin", + "role": "office_admin", + "permissions": ["journal_post", "office_config", "settlement_approve", "super_admin_portal"] + }, + { + "username": "ali_hospitallers_operator", + "role": "operator", + "permissions": ["journal_post", "transfer_initiate", "swap_settle", "super_admin_portal"] + } + ], + "separationOfDuties": { + "journalPostRequires": ["tenant_api", "office_admin", "operator"], + "materialOpeningRequires": ["office_admin", "head_office_dual_control"], + "tokenLoadRequires": ["super_admin_portal"], + "externalTransferRequires": ["super_admin_portal", "compliance_screening"] + }, + "customerApi": { + "role": "customer", + "permissions": ["quote_read", "token_list", "swap_plan", "swap_execute"], + "denied": ["token_load", "transfer_internal", "transfer_external", "swap_settle", "money_supply_admin"] + } +} diff --git a/config/omnl-super-admins.v1.json b/config/omnl-super-admins.v1.json new file mode 100644 index 0000000..6964d3d --- /dev/null +++ b/config/omnl-super-admins.v1.json @@ -0,0 +1,47 @@ +{ + "version": "1.0.0", + "description": "Four OMNL super-admin operators — one per banking portal LXC (5825–5828). API keys via env vars (never commit values).", + "superAdmins": [ + { + "id": "sa-office24", + "username": "zardasht.office24", + "displayName": "Zardasht Office 24 Super Admin", + "portalCtid": 5825, + "portalName": "omnl-office24-portal", + "envKey": "OMNL_SUPER_ADMIN_OFFICE24_KEY", + "fineractUser": "ali_hospitallers_admin", + "products": ["office-24", "digital-swap"] + }, + { + "id": "sa-exchange", + "username": "zardasht.exchange", + "displayName": "DBIS Exchange Super Admin", + "portalCtid": 5826, + "portalName": "dbis-exchange-portal", + "envKey": "OMNL_SUPER_ADMIN_EXCHANGE_KEY", + "fineractUser": "ali_hospitallers_operator", + "products": ["dbis-trade"] + }, + { + "id": "sa-secure", + "username": "zardasht.secure", + "displayName": "Central Bank / Online Banking Super Admin", + "portalCtid": 5827, + "portalName": "dbis-secure-portal", + "envKey": "OMNL_SUPER_ADMIN_SECURE_KEY", + "fineractUser": "ali_hospitallers_admin", + "products": ["central-bank", "online-banking"] + }, + { + "id": "sa-forex", + "username": "zardasht.forex", + "displayName": "Forex Trade Super Admin", + "portalCtid": 5828, + "portalName": "dbis-forex-portal", + "envKey": "OMNL_SUPER_ADMIN_FOREX_KEY", + "fineractUser": "ali_hospitallers_operator", + "products": ["forex-trade"] + } + ], + "notes": "Run scripts/deployment/seed-omnl-super-admin-keys.sh to generate keys. Each LXC receives its portal key as OMNL_API_KEY via push-portal-env-to-lxc.sh." +} diff --git a/deploy/nginx/omnl-common-locations.conf b/deploy/nginx/omnl-common-locations.conf index 57cea35..47420cf 100644 --- a/deploy/nginx/omnl-common-locations.conf +++ b/deploy/nginx/omnl-common-locations.conf @@ -1,6 +1,7 @@ # Shared OMNL Bank proxy locations (include inside each server block) location /api/v1/ { + include /etc/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_api/api/v1/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -11,6 +12,7 @@ location /api/v1/ { } location /settlement/ { + include /etc/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_settlement/api/v1/settlement/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -22,6 +24,7 @@ location /settlement/ { } location /exchange/ { + include /etc/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_exchange/api/v1/exchange/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/deploy/nginx/omnl-lxc-portal.conf b/deploy/nginx/omnl-lxc-portal.conf index 24e0930..b37013b 100644 --- a/deploy/nginx/omnl-lxc-portal.conf +++ b/deploy/nginx/omnl-lxc-portal.conf @@ -33,6 +33,7 @@ http { index index.html; location /api/v1/ { + include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_lxc_api/api/v1/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -40,6 +41,7 @@ http { } location /settlement/ { + include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_lxc_settlement/api/v1/settlement/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -47,6 +49,7 @@ http { } location /exchange/ { + include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc; proxy_pass http://omnl_lxc_exchange/api/v1/exchange/; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/deploy/nginx/omnl-portal-auth-current.conf.inc.example b/deploy/nginx/omnl-portal-auth-current.conf.inc.example new file mode 100644 index 0000000..f543d96 --- /dev/null +++ b/deploy/nginx/omnl-portal-auth-current.conf.inc.example @@ -0,0 +1,3 @@ +# Copy to /etc/nginx/omnl-portal-auth-current.conf.inc on deploy (generated with real secrets). +# proxy_set_header Authorization "Bearer "; +# proxy_set_header X-OMNL-Portal-Auth ""; diff --git a/frontend-dapp/src/config/dex.ts b/frontend-dapp/src/config/dex.ts index 2ca61e0..ce0330c 100644 --- a/frontend-dapp/src/config/dex.ts +++ b/frontend-dapp/src/config/dex.ts @@ -41,10 +41,11 @@ export function exchangeApi(path: string): string { return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path); } -/** Bearer auth for OMNL mutating APIs (token load, transfers, swap settle). */ +/** Bearer auth for OMNL mutating APIs — production portals inject via nginx; never embed operator keys in browser. */ export function omnlApiHeaders(json = true): Record { const headers: Record = {}; if (json) headers['Content-Type'] = 'application/json'; + if (import.meta.env.PROD) return headers; const key = import.meta.env.VITE_OMNL_API_KEY?.trim(); if (key) headers.Authorization = `Bearer ${key}`; return headers; diff --git a/packages/integration-foundation/dist/index.d.ts b/packages/integration-foundation/dist/index.d.ts index a9df175..c049c79 100644 --- a/packages/integration-foundation/dist/index.d.ts +++ b/packages/integration-foundation/dist/index.d.ts @@ -16,3 +16,4 @@ export * from './webhooks/verifySignature'; export * from './hybx/HttpHybxClient'; export * from './reconciliation/ReconciliationStatus'; export * from './resilience/circuitBreaker'; +export * from './omnl/omnl-api-auth'; diff --git a/packages/integration-foundation/dist/index.js b/packages/integration-foundation/dist/index.js index 88b47e7..12bbb92 100644 --- a/packages/integration-foundation/dist/index.js +++ b/packages/integration-foundation/dist/index.js @@ -32,3 +32,4 @@ __exportStar(require("./webhooks/verifySignature"), exports); __exportStar(require("./hybx/HttpHybxClient"), exports); __exportStar(require("./reconciliation/ReconciliationStatus"), exports); __exportStar(require("./resilience/circuitBreaker"), exports); +__exportStar(require("./omnl/omnl-api-auth"), exports); diff --git a/packages/integration-foundation/dist/omnl/omnl-api-auth.d.ts b/packages/integration-foundation/dist/omnl/omnl-api-auth.d.ts new file mode 100644 index 0000000..41ae9fb --- /dev/null +++ b/packages/integration-foundation/dist/omnl/omnl-api-auth.d.ts @@ -0,0 +1,44 @@ +export type OmnlAuthRole = 'super_admin' | 'customer' | 'portal_internal' | 'legacy_operator'; +export type OmnlPrincipal = { + role: OmnlAuthRole; + adminId?: string; + portalCtid?: number; + scopes?: string[]; +}; +export type SuperAdminEntry = { + id: string; + username: string; + displayName: string; + portalCtid: number; + portalName: string; + envKey: string; + fineractUser: string; + products: string[]; +}; +export type CustomerSecurityPolicy = { + version: string; + production: boolean; + neverExposeOperatorKeyInBrowser: boolean; + portalInjectAuthViaNginx: boolean; + requireApiKeyAllExchangeRoutes: boolean; + customerAllowedScopes: string[]; + superAdminOnlyScopes: string[]; + rateLimits: { + customerPerMinute: number; + superAdminPerMinute: number; + anonymousPerMinute: number; + }; + corsOrigins: string[]; +}; +export declare function loadSuperAdminRegistry(): SuperAdminEntry[]; +export declare function extractApiKey(req: { + headers: Record; + query?: Record; +}): string | undefined; +export declare function loadCustomerSecurityPolicy(): CustomerSecurityPolicy; +export declare function resolveOmnlPrincipal(req: { + headers: Record; + query?: Record; +}, key?: string): OmnlPrincipal | null; +export declare function principalHasScope(principal: OmnlPrincipal | null, scope: string): boolean; +export declare function isProductionSecurityMode(): boolean; diff --git a/packages/integration-foundation/dist/omnl/omnl-api-auth.js b/packages/integration-foundation/dist/omnl/omnl-api-auth.js new file mode 100644 index 0000000..d3ff901 --- /dev/null +++ b/packages/integration-foundation/dist/omnl/omnl-api-auth.js @@ -0,0 +1,128 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadSuperAdminRegistry = loadSuperAdminRegistry; +exports.extractApiKey = extractApiKey; +exports.loadCustomerSecurityPolicy = loadCustomerSecurityPolicy; +exports.resolveOmnlPrincipal = resolveOmnlPrincipal; +exports.principalHasScope = principalHasScope; +exports.isProductionSecurityMode = isProductionSecurityMode; +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +let superAdminCache = null; +function repoRoot() { + return process.env.OMNL_BANK_ROOT || process.env.REPO_ROOT || process.cwd(); +} +function loadSuperAdminRegistry() { + if (superAdminCache) + return superAdminCache; + const configPath = process.env.OMNL_SUPER_ADMINS_CONFIG || + path_1.default.join(repoRoot(), 'config/omnl-super-admins.v1.json'); + try { + const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8')); + superAdminCache = raw.superAdmins ?? []; + } + catch { + superAdminCache = []; + } + return superAdminCache; +} +function extractApiKey(req) { + const auth = String(req.headers.authorization || ''); + const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + const headerKey = String(req.headers['x-api-key'] ?? '').trim(); + const q = String(req.query?.access_token ?? '').trim(); + return bearer || headerKey || q || undefined; +} +function customerKeys() { + const map = new Map(); + const policy = loadCustomerSecurityPolicy(); + const scopes = policy.customerAllowedScopes; + const raw = process.env.OMNL_CUSTOMER_API_KEYS?.trim(); + if (raw) { + for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) { + const [key, scopeList] = part.split(':'); + if (!key) + continue; + map.set(key.trim(), scopeList ? scopeList.split('|').map((s) => s.trim()).filter(Boolean) : scopes); + } + } + for (const envKey of ['OMNL_CUSTOMER_API_KEY_1', 'OMNL_CUSTOMER_API_KEY_2', 'OMNL_CUSTOMER_API_KEY_3']) { + const val = process.env[envKey]?.trim(); + if (val) + map.set(val, scopes); + } + return map; +} +function loadCustomerSecurityPolicy() { + const configPath = process.env.OMNL_CUSTOMER_SECURITY_CONFIG || + path_1.default.join(repoRoot(), 'config/omnl-customer-security.production.v1.json'); + try { + return JSON.parse(fs_1.default.readFileSync(configPath, 'utf8')); + } + catch { + return { + version: '1.0.0', + production: true, + neverExposeOperatorKeyInBrowser: true, + portalInjectAuthViaNginx: true, + requireApiKeyAllExchangeRoutes: true, + customerAllowedScopes: ['read', 'trade'], + superAdminOnlyScopes: ['settle', 'token_load', 'transfer', 'admin'], + rateLimits: { customerPerMinute: 30, superAdminPerMinute: 120, anonymousPerMinute: 0 }, + corsOrigins: [], + }; + } +} +function portalInternalOk(req) { + const secret = process.env.OMNL_PORTAL_INTERNAL_SECRET?.trim(); + if (!secret) + return false; + const header = String(req.headers['x-omnl-portal-auth'] ?? '').trim(); + return header === secret; +} +function resolveOmnlPrincipal(req, key) { + if (portalInternalOk(req)) { + return { role: 'portal_internal', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] }; + } + const apiKey = key ?? extractApiKey(req); + if (!apiKey) + return null; + const legacy = process.env.OMNL_API_KEY?.trim(); + if (legacy && apiKey === legacy) { + return { role: 'legacy_operator', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] }; + } + for (const admin of loadSuperAdminRegistry()) { + const adminKey = process.env[admin.envKey]?.trim(); + if (adminKey && apiKey === adminKey) { + return { + role: 'super_admin', + adminId: admin.id, + portalCtid: admin.portalCtid, + scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'], + }; + } + } + const customers = customerKeys(); + const customerScopes = customers.get(apiKey); + if (customerScopes) { + return { role: 'customer', scopes: customerScopes }; + } + return null; +} +function principalHasScope(principal, scope) { + if (!principal) + return false; + if (principal.role === 'super_admin' || principal.role === 'legacy_operator' || principal.role === 'portal_internal') { + return true; + } + return (principal.scopes ?? []).includes(scope); +} +function isProductionSecurityMode() { + const policy = loadCustomerSecurityPolicy(); + return (policy.production || + process.env.OMNL_CUSTOMER_SECURITY_PRODUCTION === '1' || + (process.env.NODE_ENV || '').toLowerCase() === 'production'); +} diff --git a/packages/integration-foundation/src/index.ts b/packages/integration-foundation/src/index.ts index a9df175..c049c79 100644 --- a/packages/integration-foundation/src/index.ts +++ b/packages/integration-foundation/src/index.ts @@ -16,3 +16,4 @@ export * from './webhooks/verifySignature'; export * from './hybx/HttpHybxClient'; export * from './reconciliation/ReconciliationStatus'; export * from './resilience/circuitBreaker'; +export * from './omnl/omnl-api-auth'; diff --git a/packages/integration-foundation/src/omnl/omnl-api-auth.ts b/packages/integration-foundation/src/omnl/omnl-api-auth.ts new file mode 100644 index 0000000..112ba20 --- /dev/null +++ b/packages/integration-foundation/src/omnl/omnl-api-auth.ts @@ -0,0 +1,173 @@ +import fs from 'fs'; +import path from 'path'; + +export type OmnlAuthRole = 'super_admin' | 'customer' | 'portal_internal' | 'legacy_operator'; + +export type OmnlPrincipal = { + role: OmnlAuthRole; + adminId?: string; + portalCtid?: number; + scopes?: string[]; +}; + +export type SuperAdminEntry = { + id: string; + username: string; + displayName: string; + portalCtid: number; + portalName: string; + envKey: string; + fineractUser: string; + products: string[]; +}; + +export type CustomerSecurityPolicy = { + version: string; + production: boolean; + neverExposeOperatorKeyInBrowser: boolean; + portalInjectAuthViaNginx: boolean; + requireApiKeyAllExchangeRoutes: boolean; + customerAllowedScopes: string[]; + superAdminOnlyScopes: string[]; + rateLimits: { + customerPerMinute: number; + superAdminPerMinute: number; + anonymousPerMinute: number; + }; + corsOrigins: string[]; +}; + +let superAdminCache: SuperAdminEntry[] | null = null; + +function repoRoot(): string { + return process.env.OMNL_BANK_ROOT || process.env.REPO_ROOT || process.cwd(); +} + +export function loadSuperAdminRegistry(): SuperAdminEntry[] { + if (superAdminCache) return superAdminCache; + const configPath = + process.env.OMNL_SUPER_ADMINS_CONFIG || + path.join(repoRoot(), 'config/omnl-super-admins.v1.json'); + try { + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { superAdmins: SuperAdminEntry[] }; + superAdminCache = raw.superAdmins ?? []; + } catch { + superAdminCache = []; + } + return superAdminCache; +} + +export function extractApiKey(req: { + headers: Record; + query?: Record; +}): string | undefined { + const auth = String(req.headers.authorization || ''); + const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + const headerKey = String(req.headers['x-api-key'] ?? '').trim(); + const q = String(req.query?.access_token ?? '').trim(); + return bearer || headerKey || q || undefined; +} + +function customerKeys(): Map { + const map = new Map(); + const policy = loadCustomerSecurityPolicy(); + const scopes = policy.customerAllowedScopes; + const raw = process.env.OMNL_CUSTOMER_API_KEYS?.trim(); + if (raw) { + for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) { + const [key, scopeList] = part.split(':'); + if (!key) continue; + map.set( + key.trim(), + scopeList ? scopeList.split('|').map((s) => s.trim()).filter(Boolean) : scopes, + ); + } + } + for (const envKey of ['OMNL_CUSTOMER_API_KEY_1', 'OMNL_CUSTOMER_API_KEY_2', 'OMNL_CUSTOMER_API_KEY_3']) { + const val = process.env[envKey]?.trim(); + if (val) map.set(val, scopes); + } + return map; +} + +export function loadCustomerSecurityPolicy(): CustomerSecurityPolicy { + const configPath = + process.env.OMNL_CUSTOMER_SECURITY_CONFIG || + path.join(repoRoot(), 'config/omnl-customer-security.production.v1.json'); + try { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as CustomerSecurityPolicy; + } catch { + return { + version: '1.0.0', + production: true, + neverExposeOperatorKeyInBrowser: true, + portalInjectAuthViaNginx: true, + requireApiKeyAllExchangeRoutes: true, + customerAllowedScopes: ['read', 'trade'], + superAdminOnlyScopes: ['settle', 'token_load', 'transfer', 'admin'], + rateLimits: { customerPerMinute: 30, superAdminPerMinute: 120, anonymousPerMinute: 0 }, + corsOrigins: [], + }; + } +} + +function portalInternalOk(req: { headers: Record }): boolean { + const secret = process.env.OMNL_PORTAL_INTERNAL_SECRET?.trim(); + if (!secret) return false; + const header = String(req.headers['x-omnl-portal-auth'] ?? '').trim(); + return header === secret; +} + +export function resolveOmnlPrincipal( + req: { headers: Record; query?: Record }, + key?: string, +): OmnlPrincipal | null { + if (portalInternalOk(req)) { + return { role: 'portal_internal', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] }; + } + + const apiKey = key ?? extractApiKey(req); + if (!apiKey) return null; + + const legacy = process.env.OMNL_API_KEY?.trim(); + if (legacy && apiKey === legacy) { + return { role: 'legacy_operator', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] }; + } + + for (const admin of loadSuperAdminRegistry()) { + const adminKey = process.env[admin.envKey]?.trim(); + if (adminKey && apiKey === adminKey) { + return { + role: 'super_admin', + adminId: admin.id, + portalCtid: admin.portalCtid, + scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'], + }; + } + } + + const customers = customerKeys(); + const customerScopes = customers.get(apiKey); + if (customerScopes) { + return { role: 'customer', scopes: customerScopes }; + } + + return null; +} + +export function principalHasScope(principal: OmnlPrincipal | null, scope: string): boolean { + if (!principal) return false; + if (principal.role === 'super_admin' || principal.role === 'legacy_operator' || principal.role === 'portal_internal') { + return true; + } + return (principal.scopes ?? []).includes(scope); +} + +export function isProductionSecurityMode(): boolean { + const policy = loadCustomerSecurityPolicy(); + return ( + policy.production || + process.env.OMNL_CUSTOMER_SECURITY_PRODUCTION === '1' || + (process.env.NODE_ENV || '').toLowerCase() === 'production' + ); +} diff --git a/scripts/deployment/deploy-omnl-bank-production.sh b/scripts/deployment/deploy-omnl-bank-production.sh index dfb4fbf..38ac95d 100644 --- a/scripts/deployment/deploy-omnl-bank-production.sh +++ b/scripts/deployment/deploy-omnl-bank-production.sh @@ -42,6 +42,24 @@ if [ -f "$ENV_FILE" ]; then set +a fi +if [[ -f "$ENV_FILE" ]] && ! grep -q '^OMNL_SUPER_ADMIN_OFFICE24_KEY=' "$ENV_FILE" 2>/dev/null; then + log "Generating four super-admin API keys..." + bash "$REPO_DIR/scripts/deployment/seed-omnl-super-admin-keys.sh" + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +if [[ -f "$ENV_FILE" ]]; then + bash "$REPO_DIR/scripts/deployment/generate-portal-nginx-auth-snippet.sh" 5827 "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" || true + if [[ -f "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" ]]; then + sudo cp "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" /etc/nginx/omnl-portal-auth-current.conf.inc 2>/dev/null || \ + cp "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" 2>/dev/null || true + sudo nginx -t 2>/dev/null && sudo systemctl reload nginx 2>/dev/null || true + fi +fi + build_pkg() { local dir="$1" log "Building $dir..." @@ -60,7 +78,8 @@ cd "$REPO_DIR/frontend-dapp" export VITE_TOKEN_AGGREGATION_URL="${VITE_TOKEN_AGGREGATION_URL:-/api/v1}" export VITE_SETTLEMENT_MIDDLEWARE_URL="${VITE_SETTLEMENT_MIDDLEWARE_URL:-/settlement}" export VITE_DBIS_EXCHANGE_URL="${VITE_DBIS_EXCHANGE_URL:-/exchange}" -export VITE_OMNL_API_KEY="${VITE_OMNL_API_KEY:-${OMNL_API_KEY:-}}" +# Never embed operator API keys in production frontend — nginx injects server-side. +unset VITE_OMNL_API_KEY if command -v pnpm >/dev/null 2>&1; then pnpm install --frozen-lockfile 2>/dev/null || pnpm install pnpm run build @@ -84,7 +103,15 @@ start_svc() { DBIS_EXCHANGE_CONFIG="$DBIS_EXCHANGE_CONFIG" \ OMNL_SUPPORTED_CHAINS_CONFIG="$OMNL_SUPPORTED_CHAINS_CONFIG" \ OMNL_M2_TOKEN_REGISTRY="$OMNL_M2_TOKEN_REGISTRY" \ - OMNL_API_KEY="${OMNL_API_KEY:-}" \ + OMNL_API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" \ + OMNL_SUPER_ADMIN_OFFICE24_KEY="${OMNL_SUPER_ADMIN_OFFICE24_KEY:-}" \ + OMNL_SUPER_ADMIN_EXCHANGE_KEY="${OMNL_SUPER_ADMIN_EXCHANGE_KEY:-}" \ + OMNL_SUPER_ADMIN_SECURE_KEY="${OMNL_SUPER_ADMIN_SECURE_KEY:-}" \ + OMNL_SUPER_ADMIN_FOREX_KEY="${OMNL_SUPER_ADMIN_FOREX_KEY:-}" \ + OMNL_PORTAL_INTERNAL_SECRET="${OMNL_PORTAL_INTERNAL_SECRET:-}" \ + OMNL_CUSTOMER_API_KEYS="${OMNL_CUSTOMER_API_KEYS:-}" \ + OMNL_CUSTOMER_SECURITY_PRODUCTION="${OMNL_CUSTOMER_SECURITY_PRODUCTION:-1}" \ + OMNL_BANK_ROOT="$REPO_DIR" \ OMNL_FINERACT_BASE_URL="${OMNL_FINERACT_BASE_URL:-${FINERACT_BASE_URL:-}}" \ OMNL_FINERACT_TENANT="${OMNL_FINERACT_TENANT:-${FINERACT_TENANT:-omnl}}" \ OMNL_FINERACT_USER="${OMNL_FINERACT_USER:-}" \ diff --git a/scripts/deployment/generate-portal-nginx-auth-snippet.sh b/scripts/deployment/generate-portal-nginx-auth-snippet.sh new file mode 100644 index 0000000..efd46ee --- /dev/null +++ b/scripts/deployment/generate-portal-nginx-auth-snippet.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Write nginx auth snippet for portal (injects super-admin Bearer + internal header server-side). +set -euo pipefail + +REPO_DIR="${OMNL_BANK_ROOT:-$HOME/smom-dbis-138}" +ENV_FILE="${OMNL_BANK_ENV:-$REPO_DIR/.env}" +CTID="${1:-5827}" +OUT="${2:-$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc}" + +CONFIG="$REPO_DIR/config/omnl-super-admins.v1.json" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +ENV_KEY="$(node -e " +const fs = require('fs'); +const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8')); +const ct = Number('$CTID'); +const admin = cfg.superAdmins.find(a => a.portalCtid === ct); +if (!admin) process.exit(2); +console.log(admin.envKey); +")" + +API_KEY="${!ENV_KEY:-${OMNL_API_KEY:-}}" +INTERNAL="${OMNL_PORTAL_INTERNAL_SECRET:-}" + +if [[ -z "$API_KEY" ]]; then + echo "No API key for CT $CTID ($ENV_KEY unset)" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUT")" +cat >"$OUT" <"$FRAG" || true +grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|JWT_SECRET=)' "$ENV_FILE" >"$FRAG" || true + +ADMIN_ENV_KEY="$(node -e " +const fs = require('fs'); +const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8')); +const ct = Number('$CTID'); +const admin = cfg.superAdmins.find(a => a.portalCtid === ct); +if (!admin) process.exit(0); +console.log(admin.envKey); +" 2>/dev/null || true)" + +if [[ -n "$ADMIN_ENV_KEY" ]]; then + ADMIN_VAL="${!ADMIN_ENV_KEY:-}" + if [[ -n "$ADMIN_VAL" ]]; then + echo "OMNL_API_KEY=${ADMIN_VAL}" >>"$FRAG" + log "Mapped CT $CTID → $ADMIN_ENV_KEY as OMNL_API_KEY" + fi +elif [[ -n "${OMNL_API_KEY:-}" ]]; then + echo "OMNL_API_KEY=${OMNL_API_KEY}" >>"$FRAG" +fi if [[ ! -s "$FRAG" ]]; then log "No Fineract/API keys found in $ENV_FILE — skip CT $CTID" @@ -29,6 +54,14 @@ fi log "Pushing portal env keys to CT $CTID..." omnl_pve_scp "$FRAG" "${OMNL_PVE_SSH_TARGET}:/tmp/omnl-portal-env.frag" rm -f "$FRAG" + +if [[ -n "$ADMIN_ENV_KEY" ]] && [[ -n "${!ADMIN_ENV_KEY:-}" ]]; then + bash "$SCRIPT_DIR/generate-portal-nginx-auth-snippet.sh" "$CTID" "$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc" + omnl_pve_scp "$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc" "${OMNL_PVE_SSH_TARGET}:/tmp/omnl-portal-auth.conf.inc" + omnl_pve_ssh "pct push ${CTID} /tmp/omnl-portal-auth.conf.inc ${PORTAL_ROOT}/deploy/nginx/omnl-portal-auth-current.conf.inc" + omnl_pve_ssh "pct exec ${CTID} -- chmod 600 ${PORTAL_ROOT}/deploy/nginx/omnl-portal-auth-current.conf.inc" +fi + omnl_pve_ssh "pct push ${CTID} /tmp/omnl-portal-env.frag /tmp/omnl-portal-env.frag" omnl_pve_ssh "pct exec ${CTID} -- bash -s" </dev/null 2>&1; then + openssl rand -hex 32 + else + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + fi +} + +ensure_kv() { + local key="$1" + local val="$2" + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + log " $key already set — skip" + else + echo "${key}=${val}" >>"$ENV_FILE" + log " $key generated" + fi +} + +if [[ ! -f "$ENV_FILE" ]]; then + touch "$ENV_FILE" + chmod 600 "$ENV_FILE" +fi + +log "Seeding OMNL super-admin keys into $ENV_FILE" + +node -e " +const fs = require('fs'); +const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8')); +for (const a of cfg.superAdmins) console.log(a.envKey); +" | while read -r env_key; do + ensure_kv "$env_key" "$(gen_key)" +done + +ensure_kv "OMNL_PORTAL_INTERNAL_SECRET" "$(gen_key)" +ensure_kv "OMNL_CUSTOMER_SECURITY_PRODUCTION" "1" +ensure_kv "OMNL_REQUIRE_API_KEY" "1" + +if ! grep -q '^JWT_SECRET=' "$ENV_FILE" 2>/dev/null; then + ensure_kv "JWT_SECRET" "$(gen_key)" +fi + +log "Super-admin key env vars ready. Map each LXC CTID to its portal key via push-portal-env-to-lxc.sh" diff --git a/services/dbis-exchange/dist/api/routes/exchange.js b/services/dbis-exchange/dist/api/routes/exchange.js index 9fedb87..6c518a0 100644 --- a/services/dbis-exchange/dist/api/routes/exchange.js +++ b/services/dbis-exchange/dist/api/routes/exchange.js @@ -8,19 +8,7 @@ Object.defineProperty(exports, "exchangePort", { enumerable: true, get: function const settlement_1 = require("../../adapters/settlement"); const token_aggregation_1 = require("../../adapters/token-aggregation"); const swap_monitor_1 = require("../../store/swap-monitor"); -function apiKey(req) { - return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined; -} -function requireKey(req, res) { - const cfg = (0, config_1.loadExchangeConfig)(); - if (!cfg.production.requireApiKey) - return true; - const key = apiKey(req); - if (key && key === process.env.OMNL_API_KEY) - return true; - res.status(401).json({ error: 'Unauthorized' }); - return false; -} +const auth_1 = require("../../middleware/auth"); function createExchangeRouter() { const router = (0, express_1.Router)(); const cfg = (0, config_1.loadExchangeConfig)(); @@ -36,7 +24,9 @@ function createExchangeRouter() { status: 'ok', }); }); - router.get('/tokens', (_req, res) => { + router.get('/tokens', (req, res) => { + if (!(0, auth_1.requireRead)(req, res)) + return; const tokens = (0, config_1.loadExchangeTokens)(); res.json({ chainId: cfg.chainId, @@ -46,11 +36,15 @@ function createExchangeRouter() { routers: cfg.routers, }); }); - router.get('/tokens/capabilities', (_req, res) => { + router.get('/tokens/capabilities', (req, res) => { + if (!(0, auth_1.requireRead)(req, res)) + return; const registry = (0, config_1.loadM2TokenRegistry)(); res.json(registry); }); - router.get('/providers', async (_req, res) => { + router.get('/providers', async (req, res) => { + if (!(0, auth_1.requireRead)(req, res)) + return; try { res.json(await (0, token_aggregation_1.fetchProviderCapabilities)(cfg.chainId)); } @@ -59,6 +53,8 @@ function createExchangeRouter() { } }); router.get('/quote', async (req, res) => { + if (!(0, auth_1.requireRead)(req, res)) + return; try { const tokenIn = String(req.query.tokenIn || ''); const tokenOut = String(req.query.tokenOut || ''); @@ -91,6 +87,8 @@ function createExchangeRouter() { } }); router.post('/plan', async (req, res) => { + if (!(0, auth_1.requireTrade)(req, res)) + return; try { const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body; if (!tokenIn || !tokenOut || !amountIn) { @@ -113,6 +111,8 @@ function createExchangeRouter() { } }); router.post('/execute-plan', async (req, res) => { + if (!(0, auth_1.requireTrade)(req, res)) + return; try { const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body; if (!tokenIn || !tokenOut || !amountIn || !recipient) { @@ -135,6 +135,8 @@ function createExchangeRouter() { } }); router.get('/ledgers', async (req, res) => { + if (!(0, auth_1.requireRead)(req, res)) + return; try { const wallet = req.query.wallet ? String(req.query.wallet) : undefined; const serverKey = process.env.OMNL_API_KEY; @@ -165,10 +167,14 @@ function createExchangeRouter() { res.status(502).json({ error: e instanceof Error ? e.message : String(e) }); } }); - router.get('/swap/monitor', (_req, res) => { - res.json({ stats: (0, swap_monitor_1.swapStats)(), swaps: (0, swap_monitor_1.listSwaps)(Number(_req.query.limit ?? 50)) }); + router.get('/swap/monitor', (req, res) => { + if (!(0, auth_1.requireSuperAdmin)(req, res)) + return; + res.json({ stats: (0, swap_monitor_1.swapStats)(), swaps: (0, swap_monitor_1.listSwaps)(Number(req.query.limit ?? 50)) }); }); router.post('/swap/record', (req, res) => { + if (!(0, auth_1.requireTrade)(req, res)) + return; const body = req.body; const rec = (0, swap_monitor_1.recordSwap)({ officeId: cfg.officeId, @@ -188,6 +194,8 @@ function createExchangeRouter() { res.json(rec); }); router.patch('/swap/record/:id', (req, res) => { + if (!(0, auth_1.requireSuperAdmin)(req, res)) + return; const updated = (0, swap_monitor_1.updateSwap)(req.params.id, req.body); if (!updated) { res.status(404).json({ error: 'Not found' }); @@ -196,7 +204,7 @@ function createExchangeRouter() { res.json(updated); }); router.post('/swap/settle', async (req, res) => { - if (!requireKey(req, res)) + if (!(0, auth_1.requireSuperAdmin)(req, res)) return; if (!cfg.production.postSwapSettlementAudit) { res.status(403).json({ error: 'postSwapSettlementAudit disabled' }); @@ -218,7 +226,7 @@ function createExchangeRouter() { recipientAddress: String(recipientAddress ?? ''), remittanceInfo: `DBIS Exchange M2 swap settlement Office ${cfg.officeId}`, tokenLineId: lineId, - apiKey: apiKey(req), + apiKey: (0, auth_1.apiKey)(req), }); if (swapId) (0, swap_monitor_1.updateSwap)(String(swapId), { settlementRef: audit.settlementId, status: 'CONFIRMED' }); @@ -229,7 +237,7 @@ function createExchangeRouter() { } }); router.post('/token-load', async (req, res) => { - if (!requireKey(req, res)) + if (!(0, auth_1.requireSuperAdmin)(req, res)) return; try { const body = req.body; @@ -244,7 +252,7 @@ function createExchangeRouter() { amount: String(body.amount ?? '0'), recipientAddress: String(body.recipientAddress ?? ''), currency: String(body.currency ?? token?.currencyCode ?? 'USD'), - apiKey: apiKey(req) ?? process.env.OMNL_API_KEY, + apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY, }); res.status(result.phase === 'FAILED' ? 422 : 200).json(result); } @@ -253,7 +261,7 @@ function createExchangeRouter() { } }); router.post('/transfer/internal', async (req, res) => { - if (!requireKey(req, res)) + if (!(0, auth_1.requireSuperAdmin)(req, res)) return; try { const body = req.body; @@ -264,7 +272,7 @@ function createExchangeRouter() { amount: String(body.amount ?? '0'), recipientAddress: String(body.recipientAddress ?? ''), senderAddress: body.senderAddress ? String(body.senderAddress) : undefined, - apiKey: apiKey(req) ?? process.env.OMNL_API_KEY, + apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY, }); res.status(result.phase === 'FAILED' ? 422 : 200).json(result); } @@ -273,7 +281,7 @@ function createExchangeRouter() { } }); router.post('/transfer/external', async (req, res) => { - if (!requireKey(req, res)) + if (!(0, auth_1.requireSuperAdmin)(req, res)) return; try { const body = req.body; @@ -287,7 +295,7 @@ function createExchangeRouter() { beneficiaryName: body.beneficiaryName ? String(body.beneficiaryName) : undefined, currency: body.currency ? String(body.currency) : undefined, remittanceInfo: body.remittanceInfo ? String(body.remittanceInfo) : undefined, - apiKey: apiKey(req) ?? process.env.OMNL_API_KEY, + apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY, }); res.status(result.phase === 'FAILED' ? 422 : 200).json(result); } diff --git a/services/dbis-exchange/dist/middleware/auth.js b/services/dbis-exchange/dist/middleware/auth.js new file mode 100644 index 0000000..fb319b0 --- /dev/null +++ b/services/dbis-exchange/dist/middleware/auth.js @@ -0,0 +1,40 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.apiKey = apiKey; +exports.requireScope = requireScope; +exports.requireSuperAdmin = requireSuperAdmin; +exports.requireRead = requireRead; +exports.requireTrade = requireTrade; +const integration_foundation_1 = require("@dbis/integration-foundation"); +const config_1 = require("../config"); +function apiKey(req) { + return (0, integration_foundation_1.extractApiKey)(req); +} +function requireScope(req, res, scope) { + const cfg = (0, config_1.loadExchangeConfig)(); + if (!cfg.production.requireApiKey && !(0, integration_foundation_1.isProductionSecurityMode)()) + return true; + const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)(); + if (policy.requireApiKeyAllExchangeRoutes || cfg.production.requireApiKey) { + const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req); + if (!principal) { + res.status(401).json({ error: 'Unauthorized', hint: 'Valid customer or super-admin API key required' }); + return false; + } + if (!(0, integration_foundation_1.principalHasScope)(principal, scope)) { + res.status(403).json({ error: 'Forbidden', scope, role: principal.role }); + return false; + } + return true; + } + return true; +} +function requireSuperAdmin(req, res) { + return requireScope(req, res, 'admin'); +} +function requireRead(req, res) { + return requireScope(req, res, 'read'); +} +function requireTrade(req, res) { + return requireScope(req, res, 'trade'); +} diff --git a/services/dbis-exchange/dist/middleware/rate-limit.js b/services/dbis-exchange/dist/middleware/rate-limit.js new file mode 100644 index 0000000..01c0f75 --- /dev/null +++ b/services/dbis-exchange/dist/middleware/rate-limit.js @@ -0,0 +1,28 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.exchangeRateLimiter = void 0; +const express_rate_limit_1 = __importDefault(require("express-rate-limit")); +const integration_foundation_1 = require("@dbis/integration-foundation"); +function asHandler(h) { + return h; +} +exports.exchangeRateLimiter = asHandler((0, express_rate_limit_1.default)({ + windowMs: 60_000, + max: (req) => { + if (!(0, integration_foundation_1.isProductionSecurityMode)()) + return 200; + const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)(); + const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req); + if (!principal) + return policy.rateLimits.anonymousPerMinute; + if (principal.role === 'customer') + return policy.rateLimits.customerPerMinute; + return policy.rateLimits.superAdminPerMinute; + }, + message: { error: 'Too many requests — rate limit exceeded' }, + standardHeaders: true, + legacyHeaders: false, +})); diff --git a/services/dbis-exchange/dist/server.js b/services/dbis-exchange/dist/server.js index 71fe133..a90b052 100644 --- a/services/dbis-exchange/dist/server.js +++ b/services/dbis-exchange/dist/server.js @@ -6,12 +6,28 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.startServer = startServer; const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); +const integration_foundation_1 = require("@dbis/integration-foundation"); const exchange_1 = require("./api/routes/exchange"); +const rate_limit_1 = require("./middleware/rate-limit"); +function corsOptions() { + const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)(); + const origins = policy.corsOrigins.filter(Boolean); + if (!origins.length) + return {}; + return { + origin(origin, cb) { + if (!origin || origins.includes(origin)) + cb(null, true); + else + cb(new Error('CORS not allowed')); + }, + }; +} function startServer() { const app = (0, express_1.default)(); - app.use((0, cors_1.default)()); + app.use((0, cors_1.default)(corsOptions())); app.use(express_1.default.json()); - app.use('/api/v1/exchange', (0, exchange_1.createExchangeRouter)()); + app.use('/api/v1/exchange', rate_limit_1.exchangeRateLimiter, (0, exchange_1.createExchangeRouter)()); const p = (0, exchange_1.exchangePort)(); app.listen(p, () => { console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`); diff --git a/services/dbis-exchange/package-lock.json b/services/dbis-exchange/package-lock.json index 1f7405f..1295e55 100644 --- a/services/dbis-exchange/package-lock.json +++ b/services/dbis-exchange/package-lock.json @@ -8,12 +8,14 @@ "name": "dbis-exchange", "version": "1.0.0", "dependencies": { + "@dbis/integration-foundation": "file:../../packages/integration-foundation", "@dbis/settlement-core": "file:../../packages/settlement-core", "axios": "^1.15.2", "cors": "^2.8.6", "dotenv": "^16.6.1", "ethers": "^6.16.0", "express": "^5.1.0", + "express-rate-limit": "^8.5.1", "uuid": "^11.1.0" }, "devDependencies": { @@ -25,6 +27,14 @@ "typescript": "^5.9.3" } }, + "../../packages/integration-foundation": { + "name": "@dbis/integration-foundation", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } + }, "../../packages/settlement-core": { "name": "@dbis/settlement-core", "version": "0.1.0", @@ -52,6 +62,10 @@ "node": ">=12" } }, + "node_modules/@dbis/integration-foundation": { + "resolved": "../../packages/integration-foundation", + "link": true + }, "node_modules/@dbis/settlement-core": { "resolved": "../../packages/settlement-core", "link": true @@ -716,6 +730,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -964,6 +996,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/services/dbis-exchange/package.json b/services/dbis-exchange/package.json index 4440c64..e73c0ad 100644 --- a/services/dbis-exchange/package.json +++ b/services/dbis-exchange/package.json @@ -9,12 +9,14 @@ "dev": "ts-node src/index.ts" }, "dependencies": { + "@dbis/integration-foundation": "file:../../packages/integration-foundation", "@dbis/settlement-core": "file:../../packages/settlement-core", "axios": "^1.15.2", "cors": "^2.8.6", "dotenv": "^16.6.1", "ethers": "^6.16.0", "express": "^5.1.0", + "express-rate-limit": "^8.5.1", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/services/dbis-exchange/src/api/routes/exchange.ts b/services/dbis-exchange/src/api/routes/exchange.ts index 2cb7cf0..51d1f97 100644 --- a/services/dbis-exchange/src/api/routes/exchange.ts +++ b/services/dbis-exchange/src/api/routes/exchange.ts @@ -24,19 +24,7 @@ import { fetchProviderCapabilities, } from '../../adapters/token-aggregation'; import { listSwaps, recordSwap, swapStats, updateSwap } from '../../store/swap-monitor'; - -function apiKey(req: Request): string | undefined { - return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined; -} - -function requireKey(req: Request, res: Response): boolean { - const cfg = loadExchangeConfig(); - if (!cfg.production.requireApiKey) return true; - const key = apiKey(req); - if (key && key === process.env.OMNL_API_KEY) return true; - res.status(401).json({ error: 'Unauthorized' }); - return false; -} +import { apiKey, requireRead, requireSuperAdmin, requireTrade } from '../../middleware/auth'; export function createExchangeRouter(): Router { const router = Router(); @@ -55,7 +43,8 @@ export function createExchangeRouter(): Router { }); }); - router.get('/tokens', (_req, res) => { + router.get('/tokens', (req, res) => { + if (!requireRead(req, res)) return; const tokens = loadExchangeTokens(); res.json({ chainId: cfg.chainId, @@ -66,12 +55,14 @@ export function createExchangeRouter(): Router { }); }); - router.get('/tokens/capabilities', (_req, res) => { + router.get('/tokens/capabilities', (req, res) => { + if (!requireRead(req, res)) return; const registry = loadM2TokenRegistry(); res.json(registry); }); - router.get('/providers', async (_req, res) => { + router.get('/providers', async (req, res) => { + if (!requireRead(req, res)) return; try { res.json(await fetchProviderCapabilities(cfg.chainId)); } catch (e) { @@ -80,6 +71,7 @@ export function createExchangeRouter(): Router { }); router.get('/quote', async (req, res) => { + if (!requireRead(req, res)) return; try { const tokenIn = String(req.query.tokenIn || ''); const tokenOut = String(req.query.tokenOut || ''); @@ -112,6 +104,7 @@ export function createExchangeRouter(): Router { }); router.post('/plan', async (req, res) => { + if (!requireTrade(req, res)) return; try { const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record; if (!tokenIn || !tokenOut || !amountIn) { @@ -134,6 +127,7 @@ export function createExchangeRouter(): Router { }); router.post('/execute-plan', async (req, res) => { + if (!requireTrade(req, res)) return; try { const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record; if (!tokenIn || !tokenOut || !amountIn || !recipient) { @@ -156,6 +150,7 @@ export function createExchangeRouter(): Router { }); router.get('/ledgers', async (req, res) => { + if (!requireRead(req, res)) return; try { const wallet = req.query.wallet ? String(req.query.wallet) : undefined; const serverKey = process.env.OMNL_API_KEY; @@ -187,11 +182,13 @@ export function createExchangeRouter(): Router { } }); - router.get('/swap/monitor', (_req, res) => { - res.json({ stats: swapStats(), swaps: listSwaps(Number(_req.query.limit ?? 50)) }); + router.get('/swap/monitor', (req, res) => { + if (!requireSuperAdmin(req, res)) return; + res.json({ stats: swapStats(), swaps: listSwaps(Number(req.query.limit ?? 50)) }); }); router.post('/swap/record', (req, res) => { + if (!requireTrade(req, res)) return; const body = req.body as Record; const rec = recordSwap({ officeId: cfg.officeId, @@ -212,6 +209,7 @@ export function createExchangeRouter(): Router { }); router.patch('/swap/record/:id', (req, res) => { + if (!requireSuperAdmin(req, res)) return; const updated = updateSwap(req.params.id, req.body as Record); if (!updated) { res.status(404).json({ error: 'Not found' }); @@ -221,7 +219,7 @@ export function createExchangeRouter(): Router { }); router.post('/swap/settle', async (req, res) => { - if (!requireKey(req, res)) return; + if (!requireSuperAdmin(req, res)) return; if (!cfg.production.postSwapSettlementAudit) { res.status(403).json({ error: 'postSwapSettlementAudit disabled' }); return; @@ -253,7 +251,7 @@ export function createExchangeRouter(): Router { }); router.post('/token-load', async (req, res) => { - if (!requireKey(req, res)) return; + if (!requireSuperAdmin(req, res)) return; try { const body = req.body as Record; const tokens = loadExchangeTokens(); @@ -276,7 +274,7 @@ export function createExchangeRouter(): Router { }); router.post('/transfer/internal', async (req, res) => { - if (!requireKey(req, res)) return; + if (!requireSuperAdmin(req, res)) return; try { const body = req.body as Record; const result = await requestInternalTransfer({ @@ -295,7 +293,7 @@ export function createExchangeRouter(): Router { }); router.post('/transfer/external', async (req, res) => { - if (!requireKey(req, res)) return; + if (!requireSuperAdmin(req, res)) return; try { const body = req.body as Record; const result = await requestExternalTransfer({ diff --git a/services/dbis-exchange/src/middleware/auth.ts b/services/dbis-exchange/src/middleware/auth.ts new file mode 100644 index 0000000..434ce21 --- /dev/null +++ b/services/dbis-exchange/src/middleware/auth.ts @@ -0,0 +1,45 @@ +import type { Request, Response } from 'express'; +import { + extractApiKey, + isProductionSecurityMode, + loadCustomerSecurityPolicy, + principalHasScope, + resolveOmnlPrincipal, +} from '@dbis/integration-foundation'; +import { loadExchangeConfig } from '../config'; + +export function apiKey(req: Request): string | undefined { + return extractApiKey(req); +} + +export function requireScope(req: Request, res: Response, scope: string): boolean { + const cfg = loadExchangeConfig(); + if (!cfg.production.requireApiKey && !isProductionSecurityMode()) return true; + + const policy = loadCustomerSecurityPolicy(); + if (policy.requireApiKeyAllExchangeRoutes || cfg.production.requireApiKey) { + const principal = resolveOmnlPrincipal(req); + if (!principal) { + res.status(401).json({ error: 'Unauthorized', hint: 'Valid customer or super-admin API key required' }); + return false; + } + if (!principalHasScope(principal, scope)) { + res.status(403).json({ error: 'Forbidden', scope, role: principal.role }); + return false; + } + return true; + } + return true; +} + +export function requireSuperAdmin(req: Request, res: Response): boolean { + return requireScope(req, res, 'admin'); +} + +export function requireRead(req: Request, res: Response): boolean { + return requireScope(req, res, 'read'); +} + +export function requireTrade(req: Request, res: Response): boolean { + return requireScope(req, res, 'trade'); +} diff --git a/services/dbis-exchange/src/middleware/rate-limit.ts b/services/dbis-exchange/src/middleware/rate-limit.ts new file mode 100644 index 0000000..07f5a27 --- /dev/null +++ b/services/dbis-exchange/src/middleware/rate-limit.ts @@ -0,0 +1,24 @@ +import rateLimit from 'express-rate-limit'; +import type { RequestHandler } from 'express'; +import { isProductionSecurityMode, loadCustomerSecurityPolicy, resolveOmnlPrincipal } from '@dbis/integration-foundation'; + +function asHandler(h: ReturnType): RequestHandler { + return h as unknown as RequestHandler; +} + +export const exchangeRateLimiter = asHandler( + rateLimit({ + windowMs: 60_000, + max: (req) => { + if (!isProductionSecurityMode()) return 200; + const policy = loadCustomerSecurityPolicy(); + const principal = resolveOmnlPrincipal(req); + if (!principal) return policy.rateLimits.anonymousPerMinute; + if (principal.role === 'customer') return policy.rateLimits.customerPerMinute; + return policy.rateLimits.superAdminPerMinute; + }, + message: { error: 'Too many requests — rate limit exceeded' }, + standardHeaders: true, + legacyHeaders: false, + }), +); diff --git a/services/dbis-exchange/src/server.ts b/services/dbis-exchange/src/server.ts index e591ce2..bcd0ec5 100644 --- a/services/dbis-exchange/src/server.ts +++ b/services/dbis-exchange/src/server.ts @@ -1,12 +1,26 @@ import express from 'express'; import cors from 'cors'; +import { loadCustomerSecurityPolicy } from '@dbis/integration-foundation'; import { createExchangeRouter, exchangePort } from './api/routes/exchange'; +import { exchangeRateLimiter } from './middleware/rate-limit'; + +function corsOptions(): cors.CorsOptions { + const policy = loadCustomerSecurityPolicy(); + const origins = policy.corsOrigins.filter(Boolean); + if (!origins.length) return {}; + return { + origin(origin, cb) { + if (!origin || origins.includes(origin)) cb(null, true); + else cb(new Error('CORS not allowed')); + }, + }; +} export function startServer(): void { const app = express(); - app.use(cors()); + app.use(cors(corsOptions())); app.use(express.json()); - app.use('/api/v1/exchange', createExchangeRouter()); + app.use('/api/v1/exchange', exchangeRateLimiter, createExchangeRouter()); const p = exchangePort(); app.listen(p, () => { console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`); diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 3884c1e..f0f68a4 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -11,16 +11,7 @@ const office_scope_1 = require("../../workflows/office-scope"); const settlement_store_1 = require("../../store/settlement-store"); const swift_inbound_1 = require("../../workflows/swift-inbound"); const fineract_1 = require("../../adapters/fineract"); -function requireApiKey(req, res) { - const cfg = (0, config_1.loadConfig)(); - if (!cfg.production.requireApiKey) - return true; - const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key'); - if (key && key === process.env.OMNL_API_KEY) - return true; - res.status(401).json({ error: 'Unauthorized' }); - return false; -} +const auth_1 = require("../../middleware/auth"); async function respondPublicMoneySupply(res, officeId) { const cfg = (0, config_1.loadConfig)(); const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1'; @@ -117,7 +108,7 @@ function createSettlementRouter() { router.get('/public/money-supply', (_req, res) => respondPublicMoneySupply(res, officeId)); router.get('/money-supply/public', (_req, res) => respondPublicMoneySupply(res, officeId)); router.get('/money-supply', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { res.json(await (0, external_transfer_1.getMoneySupply)(officeId)); @@ -127,7 +118,7 @@ function createSettlementRouter() { } }); router.get('/money-supply/:officeIdParam', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; const requested = parseInt(req.params.officeIdParam, 10); if (!Number.isFinite(requested)) { @@ -142,7 +133,7 @@ function createSettlementRouter() { } }); router.get('/settlements', (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; res.json({ officeId, @@ -150,7 +141,7 @@ function createSettlementRouter() { }); }); router.get('/settlements/:id', (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; const rec = (0, external_transfer_1.getSettlementStatus)(req.params.id); if (!rec) { @@ -160,7 +151,7 @@ function createSettlementRouter() { res.json(rec); }); router.post('/external-transfer', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const merged = (0, office_scope_1.bindOffice24)(req.body); @@ -174,7 +165,7 @@ function createSettlementRouter() { } }); router.post('/swift/inbound', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const raw = typeof req.body === 'string' ? req.body : req.body?.raw; @@ -190,7 +181,7 @@ function createSettlementRouter() { } }); router.post('/trade-finance/:instrument', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; const instrument = req.params.instrument.toUpperCase(); if (!['SBLC', 'DLC', 'BG', 'LS'].includes(instrument)) { @@ -217,7 +208,7 @@ function createSettlementRouter() { } }); router.post('/convert/fiat-to-crypto', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const body = req.body; @@ -240,7 +231,7 @@ function createSettlementRouter() { } }); router.post('/token-load', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const body = req.body; @@ -259,7 +250,7 @@ function createSettlementRouter() { } }); router.post('/transfer/internal', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const body = req.body; @@ -280,7 +271,7 @@ function createSettlementRouter() { } }); router.post('/transfer/external', async (req, res) => { - if (!requireApiKey(req, res)) + if (!(0, auth_1.requireApiKey)(req, res)) return; try { const body = req.body; diff --git a/services/settlement-middleware/dist/middleware/auth.js b/services/settlement-middleware/dist/middleware/auth.js new file mode 100644 index 0000000..ae0d444 --- /dev/null +++ b/services/settlement-middleware/dist/middleware/auth.js @@ -0,0 +1,20 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.requireApiKey = requireApiKey; +const integration_foundation_1 = require("@dbis/integration-foundation"); +const config_1 = require("../config"); +function requireApiKey(req, res) { + const cfg = (0, config_1.loadConfig)(); + if (!cfg.production.requireApiKey) + return true; + const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req); + if (!principal) { + res.status(401).json({ error: 'Unauthorized' }); + return false; + } + if (principal.role === 'customer') { + res.status(403).json({ error: 'Forbidden', hint: 'Settlement APIs require super-admin credentials' }); + return false; + } + return true; +} diff --git a/services/settlement-middleware/dist/middleware/rate-limit.js b/services/settlement-middleware/dist/middleware/rate-limit.js new file mode 100644 index 0000000..3461f18 --- /dev/null +++ b/services/settlement-middleware/dist/middleware/rate-limit.js @@ -0,0 +1,28 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.settlementRateLimiter = void 0; +const express_rate_limit_1 = __importDefault(require("express-rate-limit")); +const integration_foundation_1 = require("@dbis/integration-foundation"); +function asHandler(h) { + return h; +} +exports.settlementRateLimiter = asHandler((0, express_rate_limit_1.default)({ + windowMs: 60_000, + max: (req) => { + if (!(0, integration_foundation_1.isProductionSecurityMode)()) + return 200; + const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)(); + const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req); + if (!principal) + return policy.rateLimits.anonymousPerMinute; + if (principal.role === 'customer') + return policy.rateLimits.customerPerMinute; + return policy.rateLimits.superAdminPerMinute; + }, + message: { error: 'Too many requests — rate limit exceeded' }, + standardHeaders: true, + legacyHeaders: false, +})); diff --git a/services/settlement-middleware/dist/server.js b/services/settlement-middleware/dist/server.js index 1d07bdc..505d97e 100644 --- a/services/settlement-middleware/dist/server.js +++ b/services/settlement-middleware/dist/server.js @@ -7,14 +7,30 @@ exports.createApp = createApp; exports.startServer = startServer; const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); +const integration_foundation_1 = require("@dbis/integration-foundation"); const settlement_1 = require("./api/routes/settlement"); +const rate_limit_1 = require("./middleware/rate-limit"); const config_1 = require("./config"); +function corsOptions() { + const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)(); + const origins = policy.corsOrigins.filter(Boolean); + if (!origins.length) + return {}; + return { + origin(origin, cb) { + if (!origin || origins.includes(origin)) + cb(null, true); + else + cb(new Error('CORS not allowed')); + }, + }; +} function createApp() { const app = (0, express_1.default)(); - app.use((0, cors_1.default)()); + app.use((0, cors_1.default)(corsOptions())); app.use(express_1.default.json({ limit: '2mb' })); app.use(express_1.default.text({ type: 'text/plain', limit: '2mb' })); - app.use('/api/v1/settlement', (0, settlement_1.createSettlementRouter)()); + app.use('/api/v1/settlement', rate_limit_1.settlementRateLimiter, (0, settlement_1.createSettlementRouter)()); return app; } function startServer() { diff --git a/services/settlement-middleware/package-lock.json b/services/settlement-middleware/package-lock.json index 8401717..a648e33 100644 --- a/services/settlement-middleware/package-lock.json +++ b/services/settlement-middleware/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.6", "dotenv": "^16.6.1", "express": "^5.1.0", + "express-rate-limit": "^8.5.1", "uuid": "^11.1.0" }, "devDependencies": { @@ -649,6 +650,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -897,6 +916,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/services/settlement-middleware/package.json b/services/settlement-middleware/package.json index d1d9bf0..1451d14 100644 --- a/services/settlement-middleware/package.json +++ b/services/settlement-middleware/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.6", "dotenv": "^16.6.1", "express": "^5.1.0", + "express-rate-limit": "^8.5.1", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 901c047..fc7f90a 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -13,15 +13,7 @@ import { bindOffice24 } from '../../workflows/office-scope'; import { settlementStore } from '../../store/settlement-store'; import { processSwiftInbound } from '../../workflows/swift-inbound'; import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract'; - -function requireApiKey(req: Request, res: Response): boolean { - const cfg = loadConfig(); - if (!cfg.production.requireApiKey) return true; - const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key'); - if (key && key === process.env.OMNL_API_KEY) return true; - res.status(401).json({ error: 'Unauthorized' }); - return false; -} +import { requireApiKey } from '../../middleware/auth'; async function respondPublicMoneySupply(res: Response, officeId: number): Promise { const cfg = loadConfig(); diff --git a/services/settlement-middleware/src/middleware/auth.ts b/services/settlement-middleware/src/middleware/auth.ts new file mode 100644 index 0000000..024a7a6 --- /dev/null +++ b/services/settlement-middleware/src/middleware/auth.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from 'express'; +import { resolveOmnlPrincipal } from '@dbis/integration-foundation'; +import { loadConfig } from '../config'; + +export function requireApiKey(req: Request, res: Response): boolean { + const cfg = loadConfig(); + if (!cfg.production.requireApiKey) return true; + const principal = resolveOmnlPrincipal(req); + if (!principal) { + res.status(401).json({ error: 'Unauthorized' }); + return false; + } + if (principal.role === 'customer') { + res.status(403).json({ error: 'Forbidden', hint: 'Settlement APIs require super-admin credentials' }); + return false; + } + return true; +} diff --git a/services/settlement-middleware/src/middleware/rate-limit.ts b/services/settlement-middleware/src/middleware/rate-limit.ts new file mode 100644 index 0000000..b928c9f --- /dev/null +++ b/services/settlement-middleware/src/middleware/rate-limit.ts @@ -0,0 +1,24 @@ +import rateLimit from 'express-rate-limit'; +import type { RequestHandler } from 'express'; +import { isProductionSecurityMode, loadCustomerSecurityPolicy, resolveOmnlPrincipal } from '@dbis/integration-foundation'; + +function asHandler(h: ReturnType): RequestHandler { + return h as unknown as RequestHandler; +} + +export const settlementRateLimiter = asHandler( + rateLimit({ + windowMs: 60_000, + max: (req) => { + if (!isProductionSecurityMode()) return 200; + const policy = loadCustomerSecurityPolicy(); + const principal = resolveOmnlPrincipal(req); + if (!principal) return policy.rateLimits.anonymousPerMinute; + if (principal.role === 'customer') return policy.rateLimits.customerPerMinute; + return policy.rateLimits.superAdminPerMinute; + }, + message: { error: 'Too many requests — rate limit exceeded' }, + standardHeaders: true, + legacyHeaders: false, + }), +); diff --git a/services/settlement-middleware/src/server.ts b/services/settlement-middleware/src/server.ts index 0a536ab..6efd7b7 100644 --- a/services/settlement-middleware/src/server.ts +++ b/services/settlement-middleware/src/server.ts @@ -1,14 +1,28 @@ import express from 'express'; import cors from 'cors'; +import { loadCustomerSecurityPolicy } from '@dbis/integration-foundation'; import { createSettlementRouter } from './api/routes/settlement'; +import { settlementRateLimiter } from './middleware/rate-limit'; import { port } from './config'; +function corsOptions(): cors.CorsOptions { + const policy = loadCustomerSecurityPolicy(); + const origins = policy.corsOrigins.filter(Boolean); + if (!origins.length) return {}; + return { + origin(origin, cb) { + if (!origin || origins.includes(origin)) cb(null, true); + else cb(new Error('CORS not allowed')); + }, + }; +} + export function createApp() { const app = express(); - app.use(cors()); + app.use(cors(corsOptions())); app.use(express.json({ limit: '2mb' })); app.use(express.text({ type: 'text/plain', limit: '2mb' })); - app.use('/api/v1/settlement', createSettlementRouter()); + app.use('/api/v1/settlement', settlementRateLimiter, createSettlementRouter()); return app; } diff --git a/services/token-aggregation/src/api/middleware/omnl-guards.ts b/services/token-aggregation/src/api/middleware/omnl-guards.ts index 77daaeb..43719ce 100644 --- a/services/token-aggregation/src/api/middleware/omnl-guards.ts +++ b/services/token-aggregation/src/api/middleware/omnl-guards.ts @@ -1,4 +1,5 @@ import type { Request, Response, NextFunction } from 'express'; +import { resolveOmnlPrincipal } from '@dbis/integration-foundation'; function extractBearerOrQuery(req: Request, key: string): boolean { const auth = String(req.headers.authorization || ''); @@ -9,6 +10,8 @@ function extractBearerOrQuery(req: Request, key: string): boolean { } function hasOmnlOperatorAuth(req: Request): boolean { + const principal = resolveOmnlPrincipal(req); + if (principal && principal.role !== 'customer') return true; const apiKey = process.env.OMNL_API_KEY?.trim(); if (apiKey && extractBearerOrQuery(req, apiKey)) return true; const dashKey = process.env.OMNL_DASHBOARD_TOKEN?.trim(); @@ -69,18 +72,14 @@ export function omnlRequireApiKeyInProduction(req: Request, res: Response, next: return; } const key = process.env.OMNL_API_KEY?.trim(); - if (!key) { + if (!key && !process.env.OMNL_SUPER_ADMIN_OFFICE24_KEY) { res.status(503).json({ - error: 'OMNL_API_KEY required in production', - hint: 'Set OMNL_API_KEY and pass Authorization: Bearer ', + error: 'OMNL super-admin API keys required in production', + hint: 'Run scripts/deployment/seed-omnl-super-admin-keys.sh', }); return; } - if (extractBearerOrQuery(req, key)) { - next(); - return; - } - if (isComplianceConsolePath(req.path) && hasOmnlOperatorAuth(req)) { + if (hasOmnlOperatorAuth(req)) { next(); return; }