Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
112 changes: 109 additions & 3 deletions src/subdomains/generic/gs/__tests__/gs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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 = {
Expand Down
99 changes: 80 additions & 19 deletions src/subdomains/generic/gs/dto/gs.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export const GsRestrictedMarker = '[RESTRICTED]';
export const GsRestrictedColumns: Record<string, string[]> = {
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'],
};

Expand Down Expand Up @@ -1355,9 +1357,12 @@ export const DebugAllowedColumns: Record<string, DebugTableSpec> = {
],
},
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',
Expand All @@ -1369,6 +1374,7 @@ export const DebugAllowedColumns: Record<string, DebugTableSpec> = {
'amlCheck',
'previousAmlReason',
'amlReason',
'amlResponsible',
'priceDefinitionAllowedDate',
'highRisk',
'transactionId',
Expand Down Expand Up @@ -1564,24 +1570,79 @@ export const DebugAllowedColumns: Record<string, DebugTableSpec> = {
},
};

// 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<string, string[]> = {
// `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<string, string[]>,
allowedColumns: Record<string, DebugTableSpec>,
exceptions: Record<string, string[]>,
): 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',
Expand Down
Loading