Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,7 @@ REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05
REQUEST_KNOWN_IPS=

CRON_JOB_DELAY=

# Optional: connection string to a throwaway Postgres for the migration specs.
# Unset, the "(real Postgres)" describe blocks skip. Existing convention.
MIGRATION_TEST_PG=
83 changes: 83 additions & 0 deletions migration/1785550000000-AddWalletPaymentsApiEnabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Partner Payments API gate (GET /v1/kyc/client/payments; the per-user path
* /kyc/client/users/:id/payments is an additional depth-defense surface behind
* KYC_CLIENT_COMPANY and is gated by the same flag in the service).
*
* GET /v1/kyc/client/payments returns PaymentWebhookData / TransactionDetailDto including
* customer IBANs, wallet addresses, on-chain hashes, amounts and rates. Access is currently
* gated by RoleGuard(UserRole.CLIENT_COMPANY). That role is granted to every wallet that has
* an address on company login (auth.service generateCompanyToken / companySignIn), so any
* new partner wallet would automatically see the full transaction dump without an explicit
* enablement decision.
*
* This migration adds wallet.paymentsApiEnabled (boolean NOT NULL DEFAULT false) — fail-closed:
* a new or unknown wallet has no payments API access until ops deliberately flip the flag
* (via the existing admin PUT /wallet/:id, which maps WalletDto including this field).
*
* Backfill: SET paymentsApiEnabled = true WHERE address IS NOT NULL.
*
* Why not isKycClient: that flag is the wrong predicate. The role CLIENT_COMPANY is granted
* precisely for wallets that can company-sign-in without being a KYC client
* (isKycClient = false). A backfill over isKycClient = true would by construction exclude
* exactly the partners the endpoint was opened for. Because 403 is excluded from WARN logging
* in exception.filter.ts, that loss of access would have gone unnoticed.
*
* Semantically, every wallet that can sign in as a company today (companySignIn requires a
* set address) keeps exactly the access it has. Newly created wallets start at false, so a
* newly set address no longer opens the payments door by itself — the purpose of the flag.
*
* Ownership: this migration creates "wallet"."paymentsApiEnabled". down() first persists the
* current per-wallet flag values into the immutable "log" table (so ops flips after deploy
* remain reconstructible), then drops only that column. No other row or column is affected.
*
* Verified on: throwaway Postgres 15 via docker (column add + backfill + drop; up → down → up
* with existing rows, with and without address, independent of isKycClient). Runs at boot via
* SQL_MIGRATE (fail-closed).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddWalletPaymentsApiEnabled1785550000000 {
name = 'AddWalletPaymentsApiEnabled1785550000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "wallet" ADD "paymentsApiEnabled" boolean NOT NULL DEFAULT false`);
// Preserve access for every wallet that can company-sign-in today (address set).
// See file header for why isKycClient is the wrong predicate.
await queryRunner.query(`UPDATE "wallet" SET "paymentsApiEnabled" = true WHERE "address" IS NOT NULL`);
}

/**
* Persist the current paymentsApiEnabled value per wallet into the immutable "log" table,
* then drop the column.
*
* After deploy, ops may flip individual wallets true/false. Dropping without a prior record
* would destroy those decisions; a later up() would only re-derive from address.
*
* Fail-closed: the audit INSERT runs before the DROP in the same migration transaction. If
* the audit write fails, the DROP is never reached and the transaction rolls back both —
* the column stays until the prior state is durable in "log".
*
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`
INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message")
SELECT now(), now(), 'Wallet', 'PaymentsApiEnabledRollback', 'Info',
COALESCE(json_agg(json_build_object(
'id', "id",
'paymentsApiEnabled', "paymentsApiEnabled"
) ORDER BY "id")::text, '[]')
FROM "wallet"
`);
await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "paymentsApiEnabled"`);
}
};
1 change: 1 addition & 0 deletions src/subdomains/generic/gs/dto/gs.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,7 @@ export const DebugAllowedColumns: Record<string, DebugTableSpec> = {
'isKycClient',
'name',
'ownerId',
'paymentsApiEnabled',
'usesDummyAddresses',
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { GetJwt } from 'src/shared/auth/get-jwt.decorator';
import { JwtPayload } from 'src/shared/auth/jwt-payload.interface';
import { RoleGuard } from 'src/shared/auth/role.guard';
import { UserRole } from 'src/shared/auth/user-role.enum';
import { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto';
import { KycClientDataDto, KycReportDto, KycReportType } from '../dto/kyc-file.dto';
import { PartnerPaymentDto } from '../dto/partner-payment.dto';
import { KycClientService } from '../services/kyc-client.service';

@ApiTags('KYC Client')
Expand All @@ -26,7 +26,7 @@ export class KycClientController {
@Get('payments')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY))
@ApiOkResponse({ type: PaymentWebhookData, isArray: true })
@ApiOkResponse({ type: PartnerPaymentDto, isArray: true })
@ApiQuery({ name: 'from', required: false, description: 'Start date filter' })
@ApiQuery({ name: 'to', required: false, description: 'End date filter' })
@ApiQuery({ name: 'limit', required: false, description: 'Maximum number of results (default/max: 1000)' })
Expand All @@ -35,7 +35,7 @@ export class KycClientController {
@Query('from') from: string,
@Query('to') to: string,
@Query('limit') limit?: string,
): Promise<PaymentWebhookData[]> {
): Promise<PartnerPaymentDto[]> {
const parsedLimit = Math.min(limit ? parseInt(limit) : 1000, 1000);

return this.kycClientService.getAllPayments(
Expand Down Expand Up @@ -69,13 +69,13 @@ export class KycClientController {
@Get('users/:id/payments')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.KYC_CLIENT_COMPANY))
@ApiOkResponse({ type: PaymentWebhookData, isArray: true })
@ApiOkResponse({ type: PartnerPaymentDto, isArray: true })
async getUserPayments(
@GetJwt() jwt: JwtPayload,
@Param('id') userId: string,
@Query('from') from: string,
@Query('to') to: string,
): Promise<PaymentWebhookData[]> {
): Promise<PartnerPaymentDto[]> {
return this.kycClientService.getAllUserPayments(
jwt.user,
userId,
Expand Down
172 changes: 172 additions & 0 deletions src/subdomains/generic/kyc/dto/__tests__/partner-payment.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { DECORATORS } from '@nestjs/swagger/dist/constants';
import { TransactionState, TransactionType } from 'src/subdomains/supporting/payment/dto/transaction.dto';
import { PaymentWebhookData } from '../../../user/services/webhook/dto/payment-webhook.dto';
import { WebhookDataMapper } from '../../../user/services/webhook/mapper/webhook-data.mapper';
import { PARTNER_PAYMENT_OMITTED_FIELDS, PartnerPaymentDto, toPartnerPaymentDto } from '../partner-payment.dto';

/**
* Locked denylist — if a sensitive field is re-added to the pull DTO (removed from this list
* or re-introduced on PartnerPaymentDto), these tests fail. Do not silently shrink this set.
*/
const EXPECTED_OMITTED_FIELDS = [
'sourceAccount',
'targetAccount',
'inputTxId',
'inputTxUrl',
'outputTxId',
'outputTxUrl',
'depositAddress',
'chargebackTarget',
'chargebackTxId',
'chargebackTxUrl',
'networkStartTx',
] as const;

describe('PartnerPaymentDto redaction', () => {
const fullPayload = (): PaymentWebhookData =>
({
id: 1,
uid: 'T1',
orderUid: 'O1',
type: TransactionType.BUY,
state: TransactionState.COMPLETED,
inputAmount: 10,
inputAsset: 'EUR',
inputAssetId: 1,
inputTxId: 'in-tx',
inputTxUrl: 'https://ex/in-tx',
depositAddress: 'dep',
chargebackTarget: 'cb-target',
chargebackAmount: 1,
chargebackAsset: 'EUR',
chargebackAssetId: 1,
chargebackTxId: 'cb-tx',
chargebackTxUrl: 'https://ex/cb-tx',
chargebackDate: new Date('2024-01-01'),
date: new Date('2024-01-02'),
exchangeRate: 1,
rate: 1,
outputAmount: 9,
outputAsset: 'BTC',
outputAssetId: 2,
outputTxId: 'out-tx',
outputTxUrl: 'https://ex/out-tx',
outputDate: new Date('2024-01-03'),
externalTransactionId: 'ext',
networkStartTx: {
txId: 'net-tx',
txUrl: 'https://ex/net-tx',
amount: 0.01,
exchangeRate: 1,
asset: 'ETH',
},
sourceAccount: 'CH93…',
targetAccount: '0xabc',
dfxReference: 42,
}) as PaymentWebhookData;

it('locks the omitted-field denylist (re-adding a sensitive pull field must fail here)', () => {
expect([...PARTNER_PAYMENT_OMITTED_FIELDS].sort()).toEqual([...EXPECTED_OMITTED_FIELDS].sort());
});

it('toPartnerPaymentDto drops every omitted field and keeps the rest', () => {
const full = fullPayload();
const reduced = toPartnerPaymentDto(full);
const omitted = new Set<string>(PARTNER_PAYMENT_OMITTED_FIELDS);

for (const key of Object.keys(full)) {
if (omitted.has(key)) {
expect(Object.prototype.hasOwnProperty.call(reduced, key)).toBe(false);
} else {
expect(Object.prototype.hasOwnProperty.call(reduced, key)).toBe(true);
expect((reduced as any)[key]).toEqual((full as any)[key]);
}
}

// Response must not expose any omitted field — iterate the denylist, not ad-hoc toBeUndefined.
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(Object.keys(reduced)).not.toContain(field);
}
});

it('does not mutate the full webhook payload', () => {
const full = fullPayload();
const before = { ...full };
toPartnerPaymentDto(full);
expect(full).toEqual(before);
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(full).toHaveProperty(field);
}
});

it('PartnerPaymentDto Swagger metadata excludes every omitted field', () => {
const meta: Record<string, unknown> =
Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES_ARRAY, PartnerPaymentDto.prototype) ?? [];
// Nest stores ':propName' entries on the prototype array and per-property metadata on the class.
const propNames = (Array.isArray(meta) ? meta : [])
.map((entry) => (typeof entry === 'string' ? entry.replace(/^:/, '') : ''))
.filter(Boolean);

// Also collect keys from per-property decorator metadata when present.
const ownProps = Object.getOwnPropertyNames(PartnerPaymentDto.prototype).filter((p) => p !== 'constructor');
const declared = new Set([...propNames, ...ownProps]);

// Prefer swagger plugin / omit-type cloned metadata when available.
const swaggerProps = Object.keys(
(Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, PartnerPaymentDto.prototype) as object) ?? {},
);
for (const name of swaggerProps) declared.add(name);

// Walk the denylist against declared PartnerPaymentDto surface.
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(declared.has(field)).toBe(false);
// OmitType must not re-expose the property on the reduced class prototype chain for swagger clone.
const propMeta = Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, PartnerPaymentDto.prototype, field);
expect(propMeta).toBeUndefined();
}
});
});

describe('Webhook payment payload stays full (push path unchanged)', () => {
it('PaymentWebhookData / mapper path still carries identifying fields', () => {
// Construct the same shape WebhookDataMapper returns — full TransactionDetailDto + dfxReference.
// We do not go through BuyCrypto entities here; the contract is that the *type* and strip boundary
// keep webhook consumers on PaymentWebhookData while pull uses PartnerPaymentDto.
const webhookPayload = {
sourceAccount: 'CH9300762011623852957',
targetAccount: '0xDEAD',
inputTxId: 'abc',
inputTxUrl: 'https://ex/abc',
outputTxId: 'def',
outputTxUrl: 'https://ex/def',
depositAddress: 'bc1q…',
chargebackTarget: 'CH…',
chargebackTxId: 'cb',
chargebackTxUrl: 'https://ex/cb',
networkStartTx: { txId: 'n', txUrl: 'https://ex/n', amount: 1, exchangeRate: 1, asset: 'ETH' },
dfxReference: 9,
inputAmount: 100,
} as PaymentWebhookData;

// Full payload retains every omitted field (webhook / mapper contract).
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(webhookPayload).toHaveProperty(field);
expect((webhookPayload as any)[field]).toBeDefined();
}

// Pull redaction is a separate function — webhook code never calls it.
const reduced = toPartnerPaymentDto(webhookPayload);
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(Object.keys(reduced)).not.toContain(field);
}
// Original webhook object untouched.
for (const field of PARTNER_PAYMENT_OMITTED_FIELDS) {
expect(webhookPayload).toHaveProperty(field);
}

// Mapper module still exports the full-data mappers used by the push path.
expect(typeof WebhookDataMapper.mapFiatCryptoData).toBe('function');
expect(typeof WebhookDataMapper.mapCryptoFiatData).toBe('function');
expect(typeof WebhookDataMapper.mapCryptoCryptoData).toBe('function');
});
});
45 changes: 45 additions & 0 deletions src/subdomains/generic/kyc/dto/partner-payment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { OmitType } from '@nestjs/swagger';
import { PaymentWebhookData } from '../../user/services/webhook/dto/payment-webhook.dto';

/**
* Identifying fields stripped from partner payment *pull* responses
* (GET /kyc/client/payments, GET /kyc/client/users/:id/payments).
*
* Webhook payloads still use the full PaymentWebhookData / TransactionDetailDto.
* Keep this list the single source of truth for the pull redaction — strip + DTO + tests.
*/
export const PARTNER_PAYMENT_OMITTED_FIELDS = [
// Customer IBAN / wallet address (TransactionDetailDto)
'sourceAccount',
'targetAccount',
// On-chain / bank transfer identifiers and explorer links
'inputTxId',
'inputTxUrl',
'outputTxId',
'outputTxUrl',
// Crypto deposit address (user- or route-bound)
'depositAddress',
// Chargeback destination and on-chain / transfer identifiers
'chargebackTarget',
'chargebackTxId',
'chargebackTxUrl',
// Nested object whose purpose is the network-start tx hash + explorer URL
'networkStartTx',
] as const;

export type PartnerPaymentOmittedField = (typeof PARTNER_PAYMENT_OMITTED_FIELDS)[number];

/**
* Reduced payment DTO for the partner pull endpoints.
* Same row set as before the payments-API gate PR; identifying account/tx fields removed.
*/
export class PartnerPaymentDto extends OmitType(PaymentWebhookData, [...PARTNER_PAYMENT_OMITTED_FIELDS]) {}

/** Drop identifying fields from a full payment payload. Does not mutate the input. */
export function toPartnerPaymentDto(full: PaymentWebhookData): PartnerPaymentDto {
const reduced = { ...full } as PaymentWebhookData & Record<string, unknown>;
for (const key of PARTNER_PAYMENT_OMITTED_FIELDS) {
delete reduced[key];
}
return reduced as PartnerPaymentDto;
}
Loading