diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index 07a5d34076..4e27add3b2 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -67,6 +67,7 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then echo " Show complete referral chain for user" echo " -T, --referral-tree " echo " Show complete referral tree (all branches)" + echo " -u, --user Resolve an email address to userDataId(s)" echo "" echo "Examples:" echo " ./scripts/db-debug.sh --anomalies 50" @@ -76,6 +77,7 @@ if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then echo " ./scripts/db-debug.sh --asset-history MaerkiBaumann/CHF 10" echo " ./scripts/db-debug.sh --referral-chain 370625" echo " ./scripts/db-debug.sh --referral-tree 370625" + echo " ./scripts/db-debug.sh --user user@example.com" echo " ./scripts/db-debug.sh \"SELECT * FROM asset LIMIT 10\"" exit 0 fi @@ -109,6 +111,8 @@ ASSET_LIMIT="10" REFERRAL_CHAIN_MODE="" REFERRAL_TREE_MODE="" TARGET_USER_ID="" +USER_LOOKUP_MODE="" +LOOKUP_MAIL="" case "${1:-}" in -a|--anomalies) @@ -153,6 +157,15 @@ case "${1:-}" in REFERRAL_TREE_MODE="1" TARGET_USER_ID="$2" ;; + -u|--user) + if [ -z "${2:-}" ]; then + echo "Error: --user requires an email address" + echo "Usage: ./scripts/db-debug.sh --user " + exit 1 + fi + USER_LOOKUP_MODE="1" + LOOKUP_MAIL="$2" + ;; *) SQL="${1:-SELECT id, name, blockchain FROM asset ORDER BY id DESC LIMIT 5}" ;; @@ -198,6 +211,26 @@ ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/ echo "Authenticated with role: $ROLE" echo "" +# --- User lookup mode (email -> userDataId) --- +if [ -n "$USER_LOOKUP_MODE" ]; then + echo "=== Resolving userDataId for: $LOOKUP_MAIL ===" + echo "" + + PAYLOAD=$(jq -n --arg mail "$LOOKUP_MAIL" '{"mail": $mail}') + RESULT=$(curl -s -X POST "$API_URL/gs/debug/user" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + if command -v jq &> /dev/null; then + echo "$RESULT" | jq . + else + echo "$RESULT" + fi + + exit 0 +fi + # --- Referral chain mode --- if [ -n "$REFERRAL_CHAIN_MODE" ]; then if ! command -v jq &> /dev/null; then diff --git a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts index 331ded6eef..ce2ae5f47a 100644 --- a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts +++ b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts @@ -26,19 +26,23 @@ import { LimitRequestService } from 'src/subdomains/supporting/support-issue/ser import { SupportIssueService } from 'src/subdomains/supporting/support-issue/services/support-issue.service'; import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto'; describe('GsService', () => { let service: GsService; let dataSource: DataSource; let appInsightsQueryService: AppInsightsQueryService; + let userDataService: UserDataService; beforeEach(() => { dataSource = createMock(); appInsightsQueryService = createMock(); + userDataService = createMock(); service = new GsService( appInsightsQueryService, - createMock(), + userDataService, createMock(), createMock(), createMock(), @@ -220,4 +224,40 @@ describe('GsService', () => { verboseSpy.mockRestore(); }); }); + + describe('resolveDebugUser', () => { + it('returns deduplicated, sorted userDataIds with the Mail search type and no PII', async () => { + // The resolved records intentionally carry PII (firstname/surname) to prove the DEBUG + // response strips it: only userDataIds may ever be returned, never personal data. + jest + .spyOn(userDataService, 'getUsersByMail') + .mockResolvedValue([ + { id: 102, firstname: 'Jane', surname: 'Doe' } as UserData, + { id: 101, firstname: 'John', surname: 'Doe' } as UserData, + { id: 101 } as UserData, + ]); + + const result = await service.resolveDebugUser('user@example.com', 'test-user'); + + expect(result).toEqual({ type: ComplianceSearchType.MAIL, userDataIds: [101, 102] }); + expect(JSON.stringify(result)).not.toContain('firstname'); + expect(JSON.stringify(result)).not.toContain('Doe'); + }); + + it('resolves the mail with onlyValidUser=false (same resolution as the compliance search)', async () => { + const spy = jest.spyOn(userDataService, 'getUsersByMail').mockResolvedValue([{ id: 1 } as UserData]); + + await service.resolveDebugUser('merged@example.com', 'test-user'); + + expect(spy).toHaveBeenCalledWith('merged@example.com', false, {}); + }); + + it('returns an empty id list when no account matches', async () => { + jest.spyOn(userDataService, 'getUsersByMail').mockResolvedValue([]); + + const result = await service.resolveDebugUser('missing@example.com', 'test-user'); + + expect(result).toEqual({ type: ComplianceSearchType.MAIL, userDataIds: [] }); + }); + }); }); diff --git a/src/subdomains/generic/gs/dto/debug-user-query.dto.ts b/src/subdomains/generic/gs/dto/debug-user-query.dto.ts new file mode 100644 index 0000000000..eb28226c19 --- /dev/null +++ b/src/subdomains/generic/gs/dto/debug-user-query.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto'; + +export class DebugUserQueryDto { + @IsNotEmpty() + @IsString() + @MaxLength(256) + mail: string; +} + +// Mirrors the compliance search result shape, but exposes only non-PII userDataIds. +export interface DebugUserResult { + type: ComplianceSearchType; + userDataIds: number[]; +} diff --git a/src/subdomains/generic/gs/gs.controller.ts b/src/subdomains/generic/gs/gs.controller.ts index 15f11227fb..210be4752d 100644 --- a/src/subdomains/generic/gs/gs.controller.ts +++ b/src/subdomains/generic/gs/gs.controller.ts @@ -9,6 +9,7 @@ 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 { DebugUserQueryDto, DebugUserResult } from './dto/debug-user-query.dto'; import { LogQueryDto, LogQueryResult } from './dto/log-query.dto'; import { SupportDataQuery, SupportReturnData } from './dto/support-data.dto'; import { GsService } from './gs.service'; @@ -63,4 +64,12 @@ export class GsController { async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise { return this.gsService.executeLogQuery(dto, jwt.address ?? `account:${jwt.account}`); } + + @Post('debug/user') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.DEBUG), UserActiveGuard()) + async resolveDebugUser(@GetJwt() jwt: JwtPayload, @Body() dto: DebugUserQueryDto): Promise { + return this.gsService.resolveDebugUser(dto.mail, jwt.address ?? `account:${jwt.account}`); + } } diff --git a/src/subdomains/generic/gs/gs.service.ts b/src/subdomains/generic/gs/gs.service.ts index ccf2610590..9c5b213105 100644 --- a/src/subdomains/generic/gs/gs.service.ts +++ b/src/subdomains/generic/gs/gs.service.ts @@ -10,6 +10,7 @@ import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.ser import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; +import { ComplianceSearchType } from 'src/subdomains/generic/support/dto/user-data-support.dto'; import { BankTxRepeatService } from 'src/subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; @@ -29,6 +30,7 @@ import { UserData } from '../user/models/user-data/user-data.entity'; import { UserDataService } from '../user/models/user-data/user-data.service'; import { UserService } from '../user/models/user/user.service'; import { DbQueryBaseDto, DbQueryDto, DbReturnData } from './dto/db-query.dto'; +import { DebugUserResult } from './dto/debug-user-query.dto'; import { DebugBlockedCols, DebugBlockedSchemas, @@ -192,6 +194,22 @@ export class GsService { }; } + // Sanctioned reverse lookup for the DEBUG tooling: the /gs/debug SQL endpoint blocks the + // user_data.mail column (PII), so an email cannot be resolved to a userDataId via SQL. This + // returns only non-PII identifiers, never the mail or any other personal data. + async resolveDebugUser(mail: string, userIdentifier: string): Promise { + this.logger.verbose(`Debug user lookup by ${userIdentifier}`); + + // Same resolution the compliance search uses for a mail; returns only userDataIds, no PII. + // No relations needed — only the id is read. + const userDataList = await this.userDataService.getUsersByMail(mail, false, {}); + const userDataIds = Util.toUniqueList(userDataList, 'id') + .map((userData) => userData.id) + .sort((a, b) => a - b); + + return { type: ComplianceSearchType.MAIL, userDataIds }; + } + async executeDebugQuery(sql: string, userIdentifier: string): Promise[]> { // 1. Parse SQL to AST for robust validation let ast;