diff --git a/.env.local.example b/.env.local.example index f6fa50ea06..c81eb259e0 100644 --- a/.env.local.example +++ b/.env.local.example @@ -54,3 +54,10 @@ CARDANO_GATEWAY_URL=https://cardano-mainnet.blockfrost.io/api/v0 # Mail service (dummy values for local development) MAIL_USER=noreply@localhost MAIL_PASS=dummy-password-for-local-dev + +# RealUnit W2W gas funding: the config requires this threshold to be a positive +# number and throws on boot if it is unset. Locally the W2W path is unreachable +# anyway, because assertW2wGasWalletFunded() rejects before the threshold is +# compared while REALUNIT_W2W_GAS_WALLET_PRIVATE_KEY/_ADDRESS are unset, so the +# value only has to satisfy the boot check. +REALUNIT_W2W_GAS_LOW_BALANCE_THRESHOLD=0.05 diff --git a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts index 1bac530afa..060e873caa 100644 --- a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts +++ b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts @@ -3,7 +3,12 @@ import { createMock } from '@golevelup/ts-jest'; import { DataSource } from 'typeorm'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { GsService } from '../gs.service'; -import { DebugQueryAuditPrefix } from '../dto/gs.dto'; +import { + assertDebugAllowlistInvariants, + DebugQueryAuditPrefix, + DebugRestrictedOverlapExceptions, + GsRestrictedColumns, +} from '../dto/gs.dto'; import { UserDataService } from '../../user/models/user-data/user-data.service'; import { UserService } from '../../user/models/user/user.service'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; @@ -331,8 +336,10 @@ describe('GsService', () => { ['transaction_risk_assessment', 'summary'], ['transaction_risk_assessment', 'result'], ['transaction_risk_assessment', 'pdf'], - // transaction_aml_check — amlResponsible can name a compliance officer; comment is free-form - ['transaction_aml_check', 'amlResponsible'], + // transaction_aml_check — `comment` is free-form internal note text and stays rejected. + // `amlResponsible` is deliberately NOT in this list any more: it is allowlisted via + // `DebugRestrictedOverlapExceptions` so the audit trail can answer who approved a + // transaction. See the dedicated tests below. ['transaction_aml_check', 'comment'], ['support_issue', 'name'], ['support_issue', 'information'], @@ -429,6 +436,105 @@ describe('GsService', () => { await expect(service.executeDebugQuery(dto, 'tester')).rejects.toThrow(/Table 'mros' is not allowed/); }); + describe('transaction_aml_check.amlResponsible (deliberate GsRestrictedColumns overlap)', () => { + it('allows selecting and filtering on amlResponsible', async () => { + const q = spyQuery([{ id: 1, amlResponsible: 'API' }]); + const dto: DebugQueryDto = { + table: 'transaction_aml_check', + select: [ + { kind: 'column', column: 'id' }, + { kind: 'column', column: 'amlResponsible' }, + ], + where: { kind: 'leaf', column: 'amlResponsible', op: DebugWhereOp.NE, value: 'API' }, + limit: 10, + }; + + await service.executeDebugQuery(dto, 'tester'); + + const [sql, params] = q.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('"transaction_aml_check"."amlResponsible"'); + expect(params).toEqual(['API']); + }); + + it('is registered as an explicit exception, not a silent allowlist widening', () => { + expect(GsRestrictedColumns['transaction_aml_check']).toContain('amlResponsible'); + expect(DebugRestrictedOverlapExceptions['transaction_aml_check']).toEqual(['amlResponsible']); + }); + + // The exception is scoped to /gs/debug. `/gs/db` masking is driven by GsRestrictedColumns + // and must stay untouched, so a non-SUPER_ADMIN caller there still sees [RESTRICTED]. + it('leaves the /gs/db masking list unchanged', () => { + expect(GsRestrictedColumns['transaction_aml_check']).toEqual(['amlResponsible', 'comment']); + }); + + // Guard against the exception list growing by accident: every excepted pair must be a + // conscious decision, so pin the full contents of the map. + it('excepts nothing beyond transaction_aml_check.amlResponsible', () => { + expect(DebugRestrictedOverlapExceptions).toEqual({ transaction_aml_check: ['amlResponsible'] }); + }); + }); + + // The assertions above only prove today's constants are consistent with each other. They + // cannot show that the guard matches per (table, column) rather than per table — with the + // current data there is no second overlap left to expose such a bug. So drive the guard + // itself with synthetic fixtures. + describe('assertDebugAllowlistInvariants (guard behaviour, synthetic fixtures)', () => { + const spec = (...columns: string[]) => ({ columns }); + + it('throws on an overlap that is not registered as an exception', () => { + expect(() => assertDebugAllowlistInvariants({ t: ['secret'] }, { t: spec('id', 'secret') }, {})).toThrow( + /contains 'secret' which is in GsRestrictedColumns/, + ); + }); + + // The core of the finding: excepting one column must not amnesty its table. + it('excepting one column does not amnesty a second overlap in the same table', () => { + expect(() => + assertDebugAllowlistInvariants({ t: ['ok', 'secret'] }, { t: spec('ok', 'secret') }, { t: ['ok'] }), + ).toThrow(/contains 'secret' which is in GsRestrictedColumns/); + }); + + it('passes for a registered overlap', () => { + expect(() => + assertDebugAllowlistInvariants({ t: ['ok'] }, { t: spec('id', 'ok') }, { t: ['ok'] }), + ).not.toThrow(); + }); + + it('passes when a restricted column is simply absent from the allowlist', () => { + expect(() => assertDebugAllowlistInvariants({ t: ['secret'] }, { t: spec('id') }, {})).not.toThrow(); + }); + + it('throws on a stale exception whose column left GsRestrictedColumns', () => { + expect(() => assertDebugAllowlistInvariants({ t: [] }, { t: spec('ok') }, { t: ['ok'] })).toThrow( + /not in GsRestrictedColumns/, + ); + }); + + it('throws on a stale exception whose column left DebugAllowedColumns', () => { + expect(() => assertDebugAllowlistInvariants({ t: ['ok'] }, { t: spec('id') }, { t: ['ok'] })).toThrow( + /not in DebugAllowedColumns/, + ); + }); + + it('throws on a stale exception for a table that is not debuggable at all', () => { + expect(() => assertDebugAllowlistInvariants({ t: ['ok'] }, {}, { t: ['ok'] })).toThrow( + /not in DebugAllowedColumns/, + ); + }); + + // Same prototype-chain hardening as the runtime table lookup. Computed keys are used so + // these are real own properties (a plain `__proto__:` in a literal would set the + // prototype instead). Without the `Object.hasOwn` guards, `allowedColumns['__proto__']` + // resolves to `Object.prototype` and `.columns.includes` would TypeError into a 500. + it('does not resolve prototype-chain keys through Object.prototype', () => { + expect(() => assertDebugAllowlistInvariants({ ['__proto__']: ['ok'] }, {}, {})).not.toThrow(); + expect(() => assertDebugAllowlistInvariants({ ['constructor']: ['ok'] }, {}, {})).not.toThrow(); + expect(() => assertDebugAllowlistInvariants({}, {}, { ['constructor']: ['ok'] })).toThrow( + /not in GsRestrictedColumns/, + ); + }); + }); + it('allows multiple safe columns in a single SELECT', async () => { const q = spyQuery([{ id: 1, name: 'BTC', blockchain: 'Bitcoin' }]); const dto: DebugQueryDto = { diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index 53e086d41b..701440b35f 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -4,8 +4,10 @@ export const GsRestrictedMarker = '[RESTRICTED]'; export const GsRestrictedColumns: Record = { asset: ['ikna'], // PII / free-form on the amlCheck audit trail: `amlResponsible` can name a real compliance officer - // and `comment` is the internal joined-AmlError / manual-note text. Neither is in the - // `transaction_aml_check` `DebugAllowedColumns` allowlist (the startup invariant below forbids overlap). + // and `comment` is the internal joined-AmlError / manual-note text. Both stay masked on `/gs/db` + // for every role below SUPER_ADMIN. `amlResponsible` is additionally reachable on `/gs/debug` — + // see `DebugRestrictedOverlapExceptions`; `comment` is not, and the startup invariant below still + // rejects it (and any other future overlap that is not listed as an explicit exception). transaction_aml_check: ['amlResponsible', 'comment'], }; @@ -1355,9 +1357,12 @@ export const DebugAllowedColumns: Record = { ], }, transaction_aml_check: { - // No `amlResponsible` (can name a real compliance officer) / `comment` (free-form internal - // AmlError names / manual note) — both are in `GsRestrictedColumns` and MUST NOT be allowlisted - // here, since /gs/debug applies no role masking. Lifecycle metadata + enum discriminators are safe. + // `amlResponsible` IS allowlisted here as a deliberate, documented exception — without it the + // audit trail answers "when was this amlCheck changed and by which code path", but never "who + // approved it", which is the question AML forensics actually needs. It is registered in + // `DebugRestrictedOverlapExceptions` so the startup invariant accepts the overlap knowingly. + // Still NO `comment` (free-form internal AmlError names / manual note): it is in + // `GsRestrictedColumns` without an exception, so the invariant keeps rejecting it. columns: [ 'id', 'created', @@ -1369,6 +1374,7 @@ export const DebugAllowedColumns: Record = { 'amlCheck', 'previousAmlReason', 'amlReason', + 'amlResponsible', 'priceDefinitionAllowedDate', 'highRisk', 'transactionId', @@ -1564,24 +1570,79 @@ export const DebugAllowedColumns: Record = { }, }; -// Invariant: `GsRestrictedColumns` is the per-role masking list that `/gs/db` applies (only -// SUPER_ADMIN sees the real value). The structured `/gs/debug` endpoint does NOT apply any -// such masking, so allowlisting any column also listed there would bypass the role -// restriction. Fail at module load if the two sets overlap so a future addition can't slip -// in silently. -for (const [table, restricted] of Object.entries(GsRestrictedColumns)) { - const spec = DebugAllowedColumns[table]; - if (!spec) continue; - for (const col of restricted) { - if (spec.columns.includes(col)) { - throw new Error( - `DebugAllowedColumns['${table}'] contains '${col}' which is in GsRestrictedColumns; ` + - `the /gs/debug endpoint does not apply role masking. Remove it from DebugAllowedColumns.`, - ); +// Columns that are deliberately reachable on `/gs/debug` even though `/gs/db` masks them for +// every role below SUPER_ADMIN. Each entry is an explicit, reviewed decision to accept that a +// DEBUG-role caller sees the real value on the structured endpoint — NOT a general relaxation: +// `/gs/db` masking is unchanged, and every overlap that is not listed here still aborts module +// load below. +// +// Add an entry ONLY when all of the following hold, and record the reasoning in the comment: +// - the value is needed to answer an operational/forensic question the endpoint exists for, +// - the `/gs/debug` role gate (`UserRole.DEBUG`) is an acceptable audience for it, +// - every call is attributable — `executeDebugQuery` audit-logs caller and query. +export const DebugRestrictedOverlapExceptions: Record = { + // `transaction_aml_check.amlResponsible` names the compliance staff member (or 'API') behind an + // amlCheck transition. Without it the trail shows the transition and its `AmlSourceType`, but the + // accountable actor is unreachable on /gs/debug, so "who approved this transaction" could not be + // answered from the audit trail at all. Exposed to the DEBUG role by operator decision; access + // stays attributable through the endpoint's audit log. `comment` on the same table is NOT + // excepted — it is free-form internal note text and remains rejected. + transaction_aml_check: ['amlResponsible'], +}; + +/** + * Invariant: `GsRestrictedColumns` is the per-role masking list that `/gs/db` applies (only + * SUPER_ADMIN sees the real value). The structured `/gs/debug` endpoint does NOT apply any + * such masking, so allowlisting any column also listed there would bypass the role + * restriction. Throw so a future addition can't slip in silently — unless the exact + * `(table, column)` pair is registered in the exceptions map, which makes the bypass an + * explicit, documented decision instead of an accident. + * + * The counter-check keeps the exceptions map honest: an exception only means anything for a + * pair that actually overlaps and is actually allowlisted. A stale entry (column dropped from + * either list) would silently keep a future re-add unguarded, so it fails loudly too. + * + * Kept as a pure function over its three inputs so the guard itself is testable with synthetic + * fixtures — asserting the real constants only proves today's data is consistent, not that the + * exception matches per column rather than per table. + */ +export function assertDebugAllowlistInvariants( + restrictedColumns: Record, + allowedColumns: Record, + exceptions: Record, +): void { + for (const [table, restricted] of Object.entries(restrictedColumns)) { + if (!Object.hasOwn(allowedColumns, table)) continue; + const spec = allowedColumns[table]; + const excepted = Object.hasOwn(exceptions, table) ? exceptions[table] : []; + for (const col of restricted) { + if (spec.columns.includes(col) && !excepted.includes(col)) + throw new Error( + `DebugAllowedColumns['${table}'] contains '${col}' which is in GsRestrictedColumns; ` + + `the /gs/debug endpoint does not apply role masking. Remove it from DebugAllowedColumns ` + + `or register it in DebugRestrictedOverlapExceptions with a documented reason.`, + ); + } + } + + for (const [table, excepted] of Object.entries(exceptions)) { + for (const col of excepted) { + if (!(Object.hasOwn(restrictedColumns, table) && restrictedColumns[table].includes(col))) + throw new Error( + `DebugRestrictedOverlapExceptions['${table}'] lists '${col}', which is not in ` + + `GsRestrictedColumns['${table}']; the exception is stale — remove it.`, + ); + if (!(Object.hasOwn(allowedColumns, table) && allowedColumns[table].columns.includes(col))) + throw new Error( + `DebugRestrictedOverlapExceptions['${table}'] lists '${col}', which is not in ` + + `DebugAllowedColumns['${table}']; the exception is stale — remove it.`, + ); } } } +assertDebugAllowlistInvariants(GsRestrictedColumns, DebugAllowedColumns, DebugRestrictedOverlapExceptions); + // Support endpoint export enum SupportTable { USER_DATA = 'userData',