fix: trust tbank grpc root certificate
All checks were successful
All checks were successful
This commit is contained in:
parent
ea916dfec9
commit
8c2a6c9e3c
@ -54,6 +54,12 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
||||
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
|
||||
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
|
||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker |
|
||||
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
|
||||
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
|
||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
|
||||
|
||||
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
@ -0,0 +1,33 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||
-----END CERTIFICATE-----
|
||||
@ -17,6 +17,7 @@ export default registerAs('app', () => ({
|
||||
tbank: {
|
||||
token: process.env.T_BANK_TOKEN || '',
|
||||
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
||||
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
|
||||
describe('TBankClientService', () => {
|
||||
@ -41,6 +44,30 @@ describe('TBankClientService', () => {
|
||||
expect(() => service.getServiceClient('UsersService')).not.toThrow();
|
||||
});
|
||||
|
||||
it('creates grpc SSL credentials with configured custom CA certificate', () => {
|
||||
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
||||
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
||||
const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl');
|
||||
const caConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
'app.tbank.token': 'token-1',
|
||||
'app.tbank.caCertPath': caPath,
|
||||
};
|
||||
|
||||
return values[key] ?? fallback;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
const service = new TBankClientService(caConfig);
|
||||
|
||||
service.getServiceClient('UsersService');
|
||||
|
||||
const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? [];
|
||||
expect(Buffer.isBuffer(rootCerts)).toBe(true);
|
||||
expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca');
|
||||
createSslSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws integration unavailable when token is missing', async () => {
|
||||
const missingConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) =>
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
} from '@grpc/grpc-js';
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import PQueue from 'p-queue';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
|
||||
|
||||
@ -108,7 +108,7 @@ export class TBankClientService {
|
||||
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
|
||||
const client = new ServiceCtor(
|
||||
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
|
||||
ChannelCredentials.createSsl(),
|
||||
this.createChannelCredentials(),
|
||||
);
|
||||
|
||||
this.clientCache.set(serviceName, client);
|
||||
@ -148,6 +148,22 @@ export class TBankClientService {
|
||||
);
|
||||
}
|
||||
|
||||
private createChannelCredentials(): ChannelCredentials {
|
||||
const caCertPath = this.configService.get<string>('app.tbank.caCertPath', '');
|
||||
if (!caCertPath) return ChannelCredentials.createSsl();
|
||||
|
||||
try {
|
||||
return ChannelCredentials.createSsl(readFileSync(caCertPath));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to read T-Bank CA certificate from ${caCertPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
|
||||
}
|
||||
}
|
||||
|
||||
private mapGrpcError(label: string, error: ServiceError): Error {
|
||||
const trackingId = error.metadata?.get('x-tracking-id')?.[0];
|
||||
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];
|
||||
|
||||
@ -26,6 +26,7 @@ describe('T-Bank configuration', () => {
|
||||
it('reads T-Bank token and TTL overrides from environment', () => {
|
||||
process.env.T_BANK_TOKEN = 'secret-token';
|
||||
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443';
|
||||
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
|
||||
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
|
||||
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
|
||||
|
||||
@ -33,6 +34,7 @@ describe('T-Bank configuration', () => {
|
||||
|
||||
expect(config.tbank.token).toBe('secret-token');
|
||||
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443');
|
||||
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
|
||||
expect(config.tbank.rateLimitPerSecond).toBe(2);
|
||||
expect(config.cache.tbankAccountsTtl).toBe(120);
|
||||
});
|
||||
|
||||
@ -16,8 +16,8 @@ describe('TBankController', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('limits broker endpoints to admins because they use a server-side T-Bank token', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['admin']);
|
||||
it('allows authenticated users to access broker endpoints', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']);
|
||||
});
|
||||
|
||||
it('returns accounts in a single API envelope', async () => {
|
||||
@ -43,7 +43,7 @@ describe('TBankController', () => {
|
||||
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('exposes an admin sync trigger for durable operation history', async () => {
|
||||
it('exposes a sync trigger for durable operation history', async () => {
|
||||
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, operations, sync);
|
||||
|
||||
@ -17,7 +17,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||
|
||||
@ApiTags('Broker')
|
||||
@ApiBearerAuth()
|
||||
@Roles('admin')
|
||||
@Roles('user')
|
||||
@Controller('broker')
|
||||
export class TBankController {
|
||||
constructor(
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker |
|
||||
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest |
|
||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
|
||||
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
|
||||
@ -45,6 +46,7 @@ registerAs('app', () => ({
|
||||
tbank: {
|
||||
token: process.env.T_BANK_TOKEN || '',
|
||||
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443',
|
||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||
|
||||
@ -149,7 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч
|
||||
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
|
||||
- `BrokerOperationsService` отдаёт cursor-paginated историю операций
|
||||
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
|
||||
- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side
|
||||
- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
|
||||
`T_BANK_TOKEN`
|
||||
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
|
||||
`PortfolioModule`
|
||||
|
||||
@ -27,9 +27,14 @@ x-app-name: ksv741.moex-vibe
|
||||
|
||||
Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи.
|
||||
|
||||
Если gRPC-запросы падают с `self-signed certificate in certificate chain`, значит Node.js не доверяет
|
||||
root CA, которым локальная сеть или proxy подписывает TLS-цепочку. В этом случае нужно экспортировать
|
||||
доверенный root certificate в PEM-файл и указать путь в `T_BANK_CA_CERT_PATH`. Проверку TLS отключать
|
||||
не нужно.
|
||||
|
||||
## Backend endpoints
|
||||
|
||||
Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`.
|
||||
Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`.
|
||||
|
||||
| Endpoint | Описание |
|
||||
|---|---|
|
||||
@ -63,14 +68,14 @@ Direct-read endpoints используют короткий in-memory cache, ч
|
||||
- `BrokerOperation` — нормализованная операция и сырой JSON payload;
|
||||
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
|
||||
|
||||
Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query
|
||||
параметрами `from` и `to` в ISO-8601 формате.
|
||||
Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from`
|
||||
и `to` в ISO-8601 формате.
|
||||
|
||||
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
Текущая версия рассчитана на single-user/admin сценарий: используется один server-side
|
||||
`T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед
|
||||
multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку
|
||||
каждого брокерского счёта к владельцу.
|
||||
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
|
||||
broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом
|
||||
нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского
|
||||
счёта к владельцу.
|
||||
|
||||
@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
{portfolio.totalValue?.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user