Skip to content
Merged
661 changes: 109 additions & 552 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"nestjs-i18n": "^10.5.1",
"nestjs-real-ip": "^2.2.0",
"node-2fa": "^2.0.3",
"node-sql-parser": "^5.3.13",
"nodemailer": "^6.10.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
Expand Down
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,9 @@ export class Configuration {
?.replace('BlobEndpoint=', ''),
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
},
appInsights: {
appId: process.env.APPINSIGHTS_APP_ID,
},
};

alby = {
Expand Down
81 changes: 81 additions & 0 deletions src/integration/infrastructure/app-insights-query.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { Config } from 'src/config/config';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { HttpService } from 'src/shared/services/http.service';

interface AppInsightsQueryResponse {
tables: {
name: string;
columns: { name: string; type: string }[];
rows: unknown[][];
}[];
}

@Injectable()
export class AppInsightsQueryService {
private readonly logger = new DfxLogger(AppInsightsQueryService);

private readonly baseUrl = 'https://api.applicationinsights.io/v1';
private readonly TOKEN_REFRESH_BUFFER_MS = 60000;

private accessToken: string | null = null;
private tokenExpiresAt = 0;

constructor(private readonly http: HttpService) {}

async query(kql: string, timespan?: string): Promise<AppInsightsQueryResponse> {
const appId = Config.azure.appInsights?.appId;
if (!appId) {
throw new Error('App Insights App ID not configured');
}

const body: { query: string; timespan?: string } = { query: kql };
if (timespan) body.timespan = timespan;

return this.request<AppInsightsQueryResponse>(`apps/${appId}/query`, body);
}

private async request<T>(url: string, body: object, nthTry = 3): Promise<T> {
try {
if (!this.accessToken || Date.now() >= this.tokenExpiresAt - this.TOKEN_REFRESH_BUFFER_MS) {
await this.refreshAccessToken();
}

return await this.http.request<T>({
url: `${this.baseUrl}/${url}`,
method: 'POST',
data: body,
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
} catch (e) {
if (nthTry > 1 && e.response?.status === 401) {
await this.refreshAccessToken();
return this.request(url, body, nthTry - 1);
}
throw e;
}
}

private async refreshAccessToken(): Promise<void> {
try {
const { access_token, expires_in } = await this.http.post<{ access_token: string; expires_in: number }>(
`https://login.microsoftonline.com/${Config.azure.tenantId}/oauth2/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: Config.azure.clientId,
client_secret: Config.azure.clientSecret,
resource: 'https://api.applicationinsights.io',
}),
);

this.accessToken = access_token;
this.tokenExpiresAt = Date.now() + expires_in * 1000;
} catch (e) {
this.logger.error('Failed to refresh App Insights access token', e);
throw new Error('Failed to authenticate with App Insights');
}
}
}
4 changes: 3 additions & 1 deletion src/integration/integration.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BlockchainModule } from './blockchain/blockchain.module';
import { CheckoutModule } from './checkout/checkout.module';
import { ExchangeModule } from './exchange/exchange.module';
import { IknaModule } from './ikna/ikna.module';
import { AppInsightsQueryService } from './infrastructure/app-insights-query.service';
import { AzureService } from './infrastructure/azure-service';
import { LetterModule } from './letter/letter.module';
import { SiftModule } from './sift/sift.module';
Expand All @@ -21,7 +22,7 @@ import { SiftModule } from './sift/sift.module';
SiftModule,
],
controllers: [],
providers: [AzureService],
providers: [AzureService, AppInsightsQueryService],
exports: [
BankIntegrationModule,
BlockchainModule,
Expand All @@ -30,6 +31,7 @@ import { SiftModule } from './sift/sift.module';
IknaModule,
CheckoutModule,
AzureService,
AppInsightsQueryService,
SiftModule,
],
})
Expand Down
1 change: 1 addition & 0 deletions src/shared/auth/role.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class RoleGuardClass implements CanActivate {
[UserRole.COMPLIANCE]: [UserRole.ADMIN, UserRole.SUPER_ADMIN],
[UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN],
[UserRole.ADMIN]: [UserRole.SUPER_ADMIN],
[UserRole.DEBUG]: [UserRole.ADMIN, UserRole.SUPER_ADMIN],
};

constructor(private readonly entryRole: UserRole) {}
Expand Down
1 change: 1 addition & 0 deletions src/shared/auth/user-role.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum UserRole {
SUPPORT = 'Support',
COMPLIANCE = 'Compliance',
CUSTODY = 'Custody',
DEBUG = 'Debug',

// service roles
BANKING_BOT = 'BankingBot',
Expand Down
2 changes: 2 additions & 0 deletions src/shared/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [
bic_candidates: [{ bic: 'MOCKBIC1XXX' }],
},
},
{ pattern: /login\.microsoftonline\.com/, response: { access_token: 'mock-token', expires_in: 3600 } },
{ pattern: /api\.applicationinsights\.io/, response: { tables: [{ name: 'PrimaryResult', columns: [], rows: [] }] } },
];

@Injectable()
Expand Down
8 changes: 8 additions & 0 deletions src/subdomains/generic/gs/dto/debug-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';

export class DebugQueryDto {
@IsNotEmpty()
@IsString()
@MaxLength(10000)
sql: string;
}
47 changes: 47 additions & 0 deletions src/subdomains/generic/gs/dto/log-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { IsEnum, IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';

export enum LogQueryTemplate {
TRACES_BY_OPERATION = 'traces-by-operation',
TRACES_BY_MESSAGE = 'traces-by-message',
EXCEPTIONS_RECENT = 'exceptions-recent',
REQUEST_FAILURES = 'request-failures',
DEPENDENCIES_SLOW = 'dependencies-slow',
CUSTOM_EVENTS = 'custom-events',
}

export class LogQueryDto {
@IsEnum(LogQueryTemplate)
template: LogQueryTemplate;

@IsOptional()
@IsString()
@Matches(/^[a-f0-9-]{36}$/i, { message: 'operationId must be a valid GUID' })
operationId?: string;

@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_\-.: ()]{1,100}$/, { message: 'messageFilter must be alphanumeric with basic punctuation (max 100 chars)' })
messageFilter?: string;

@IsOptional()
@IsInt()
@Min(1)
@Max(168) // max 7 days
hours?: number;

@IsOptional()
@IsInt()
@Min(100)
@Max(5000)
durationMs?: number;

@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_]{1,50}$/, { message: 'eventName must be alphanumeric' })
eventName?: string;
}

export class LogQueryResult {
columns: { name: string; type: string }[];
rows: unknown[][];
}
18 changes: 18 additions & 0 deletions src/subdomains/generic/gs/gs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { UserActiveGuard } from 'src/shared/auth/user-active.guard';
import { UserRole } from 'src/shared/auth/user-role.enum';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { DbQueryBaseDto, DbQueryDto, DbReturnData } from './dto/db-query.dto';
import { DebugQueryDto } from './dto/debug-query.dto';
import { LogQueryDto, LogQueryResult } from './dto/log-query.dto';
import { SupportDataQuery, SupportReturnData } from './dto/support-data.dto';
import { GsService } from './gs.service';

Expand Down Expand Up @@ -45,4 +47,20 @@ export class GsController {
async getSupportData(@Query() query: SupportDataQuery): Promise<SupportReturnData> {
return this.gsService.getSupportData(query);
}

@Post('debug')
@ApiBearerAuth()
@ApiExcludeEndpoint()
@UseGuards(AuthGuard(), RoleGuard(UserRole.DEBUG), UserActiveGuard())
async executeDebugQuery(@GetJwt() jwt: JwtPayload, @Body() dto: DebugQueryDto): Promise<Record<string, unknown>[]> {
return this.gsService.executeDebugQuery(dto.sql, jwt.address ?? `account:${jwt.account}`);
}

@Post('debug/logs')
@ApiBearerAuth()
@ApiExcludeEndpoint()
@UseGuards(AuthGuard(), RoleGuard(UserRole.DEBUG), UserActiveGuard())
async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise<LogQueryResult> {
return this.gsService.executeLogQuery(dto, jwt.address ?? `account:${jwt.account}`);
}
}
2 changes: 2 additions & 0 deletions src/subdomains/generic/gs/gs.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { BlockchainModule } from 'src/integration/blockchain/blockchain.module';
import { IntegrationModule } from 'src/integration/integration.module';
import { LetterModule } from 'src/integration/letter/letter.module';
import { SharedModule } from 'src/shared/shared.module';
import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module';
Expand All @@ -24,6 +25,7 @@ import { GsService } from './gs.service';
imports: [
SharedModule,
BlockchainModule,
IntegrationModule,
AddressPoolModule,
ReferralModule,
BuyCryptoModule,
Expand Down
Loading