Skip to content

feat(auth): grant the Support role on DEV for support-dashboard measurements - #4441

Open
joshuakrueger-dfx wants to merge 9 commits into
developfrom
feat/dev-support-role-migration
Open

feat(auth): grant the Support role on DEV for support-dashboard measurements#4441
joshuakrueger-dfx wants to merge 9 commits into
developfrom
feat/dev-support-role-migration

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What changed

One migration that grants the Support role on DEV only to a single wallet address, so the support-dashboard endpoints become reachable there.

  • Both directions return immediately unless ENVIRONMENT === 'dev'. The grant is scoped to DEV, and whether the same address exists elsewhere is unverified, so no other environment is ever touched.
  • up() only promotes UserSupport. An address holding Compliance, Admin, SuperAdmin or Support is left untouched.
  • down() only restores User where the role is still Support and the up() audit row reports a non-zero affected count. An address that already held Support before this migration keeps it.
  • Address comparison lowercases both sides.

Auditability

CONTRIBUTING.md requires that an overwritten value stay reconstructible from the database: when a field changed, from which value, and to which. Both directions therefore write one row into the existing log table.

  • The UPDATE and the audit insert are one statement (WITH updated AS (UPDATE … RETURNING id) INSERT INTO "log" … SELECT … FROM updated), so a failing audit write aborts the role change. Both merged precedents use this shape: 1783780521095-PurgeRealUnitRegistrationSteps.js:107,155 and 1785500000000-ClearDevUserSignatures.js:54,61,75.
  • The row is written even when no user matched, which is what makes "ran and changed nothing" distinguishable from "never ran".
  • It carries the migration name, direction, affected row count, affected user ids and both roles; the change time is the row's created. No address and no other personal data.
  • fromRole is the constant User rather than a value read back from the row. The predicate constrains the update to rows in exactly that role, so the previous value is User for every affected row by construction.
  • down() does not delete the up() row; it appends its own rollback row.

system follows the table, not the domain. The rows carry system = 'User', subsystem = 'GrantSupportRoleOnDev'. Both comparable audit migrations on develop name system after the entity they mutate — FixFiatOutputValutaDateSerials writes 'FiatOutput' for fiat_output, ClearDevUserSignatures writes 'User' for user. down()'s ownership guard looks the up() row up by system, so the two are kept in step; the down()-after-up() case fails if they drift apart.

Blast radius of the new rows. No existing reader matches that pair. Line numbers below are as of develop 213d4b5b4.

  • Every content-selecting query in log.repository.ts is scoped by system and subsystem: as find conditions at :119-120, :131, :141, :149, :176-177 and :243-244, through a correlated subquery at :158-159 and :206-207, and as raw parameter conditions at :297 and :404.
  • No query anywhere scopes by system alone, which is what keeps these rows separate from ClearDevUserSignatures' rows in the same 'User' system.
  • The validity sweep that mutates log.valid (setFinancialLogValidity, :599) inherits LogService/FinancialDataLog scoping from addFinancialLogValidityConditions (:625-626), so it cannot alter the audit rows.
  • The cleanup job deletes only per configured (system, subsystem) pair (:97-98), for which no setting exists here.
  • The only queries not scoped by system are the pagination-cursor lookups (:228, :262, :569). Each resolves one id taken from an already-scoped page, so it cannot pull these rows into a result set.

Why

PR #4344 was closed because the performance premise was never validated against real request timings, with the explicit ask to measure the list query and getMessageStats separately first. That measurement needs Staff access on DEV.

Measured on DEV before this change: a wallet-signature token for that address (role: User) reaches GET /v1/user with 200, but the dashboard routes on support-issue.controller.ts return 403. The GET routes gated by RoleGuard(UserRole.SUPPORT) are list (:96), counts (:107), statistics (:115), escalation/telegram-chats (:126), activity (:150), clerks (:161), clerk (:169) and :id/data (:188); the @UseGuards line sits two to three lines below each route decorator, after the Swagger annotations. Not every GET on that controller is gated: @Get() requires only ACCOUNT (:89), and @Get(':id') (:177) plus the message-file route (:224) use optional JWT auth.

A mail login did not close the gap: getMailLoginUser(StaffRoles) resolves only Compliance, Support and RealUnit (shared/auth/user-role.enum.ts:27, user-data.entity.ts:720), the account carried a single address in role User, and no other active staff user existed on it — the function searches all users of the account.

Consequence worth stating. getMailLoginUser() matches a user holding a staff role that also has a wallet and is not blocked or deleted. Once this migration has run, the granted user meets the role condition, so if it also carries a wallet, the account's mail login yields a Support token and the private key is no longer required for this access. That is acceptable on DEV, but it is a widening of the access path and is called out here rather than left to be discovered.

Impact

DEV data only, one row, one column, plus one audit row per direction. No schema change, no endpoint, guard, role enum or role hierarchy change. On every non-DEV environment both directions are a no-op.

The role is carried as a JWT claim, so an already-issued token keeps its old role — a fresh login is required after this runs.

Scope of the grant

Support is the weakest role that reaches the dashboard endpoints, so the choice of role is minimal. It is not narrow in what else it opens: the same role gates user search and detail views, support notes and templates, mutating user/account/KYC endpoints and owner-independent transaction detail.

The grant is not left in place. It exists for the #4344 measurement and is removed again once the timings are recorded. The removal is a separate follow-up migration rather than a rollback: migrationsRun executes pending migrations at boot (config.ts:265) and nothing in the repo invokes down(), so there is no operational revert path for a merged migration. down() remains the correct TypeORM inverse and is exercised by the specs, but it is not the mechanism that will be used. That follow-up is owed as its own PR and is not implied by this one.

Tests

src/subdomains/generic/user/models/user/__tests__/grant-support-role-on-dev.migration.spec.ts — 18 tests: 10 pin the environment gate, 2 pin the statement shape and its SQL, and 6 run the migration against a throwaway Postgres schema via MIGRATION_TEST_PG. Every Test (shard n/3) job starts its own postgres:16 service and sets that variable for the run (api-pr.yaml:174-181,210) — deliberately on all three shards, because Jest distributes the migration suites across them — so all 18 run in CI.

The behavioural cases cover what string matching cannot: a second User address stays untouched, a target address holding Compliance is left alone, the audit row carries the right affected count, down() after up() restores User, down() without a promoting up() leaves an existing Support role in place, and the address match is case-insensitive. The environment gate is table-driven over prd, stg, loc, the empty string and an unset value, per direction; %p rather than %s keeps the empty string and the unset case distinguishable in the report.

The predicate is additionally asserted as one contiguous LOWER(…) AND "role" = … fragment rather than as separate toContain calls, so an OR mutant cannot pass the string layer either.

Counter-checks, each measured on this branch against a real Postgres

  • 5 of 18 tests fail against the migration without the audit row; the suite goes red.
  • Mutation probes, each turning the suite red: ANDOR in up() (3 tests), the same in down() (2), LOWER() dropped in both directions (3), the down() ownership guard removed (2), affectedCount pinned to 0 (2), the environment gate inverted (17), the single statement split into a separate UPDATE and INSERT (3), and the audit system value changed to something else (2).
  • Migration immutability stays green: the migration is an addition against develop, and the gate only blocks M/D/R/T entries.

Not covered: the name property is not asserted against the file name, so a future edit could desynchronise the two without a test failing. And none of this ran against the real DEV database — whether exactly one row there matches the address in role User is not established by these tests.

Checks

  • Repo gate on the exact head of this branch, on a 28-core machine: npm run lint empty output, npm run format:check clean, npm run type-check exit 0, npm run build exit 0, and npm test exit 0 with Test Suites: 7 skipped, 321 passed, 321 of 328 total and Tests: 159 skipped, 5817 passed, 5976 total. The 7 skipped suites are the MIGRATION_TEST_PG specs, which that machine has no Postgres for; those 6 tests were verified separately against a real postgres:16, where the spec reports Test Suites: 1 passed and Tests: 18 passed, 18 total with nothing skipped.
  • Timestamp 1785550000000 is above the current highest migration on develop (1785500000000-ClearDevUserSignatures). It was raised twice on this branch: develop moved past the original 1785311300000, and the replacement 1785470000000 was then taken by AddTradingOrderCreatedIndex, which would have left the order between the two undefined. The immutability check blocks renames once a migration is merged, so the correction is only possible before that.
  • The branch is 36 commits behind develop and reports MERGEABLE; it is not rebased, because that would mean force-pushing a branch that is already under review.

@joshuakrueger-dfx
joshuakrueger-dfx marked this pull request as ready for review July 29, 2026 10:19
@TaprootFreak

Copy link
Copy Markdown
Collaborator

Reviewed across two passes — conformance against CONTRIBUTING.md, and logic/correctness in context. One blocking item, four minor ones.

Blocking

migration/1785311300000-GrantSupportRoleOnDev.js:25 (and :42 in down()) — no audit trail for the role transition.

CONTRIBUTING.md:565-590 ("Auditable mutations — no destructive overwrites (CRITICAL)") requires that an overwritten value stay recoverable from the database: "it must always be clear when a field changed, from which previous value, and to which new value", and explicitly lists as not allowed an "in-place UPDATE that replaces or nulls a field when the old value exists nowhere else". Line 588 asks the reviewer to reject when the previous value and change time cannot be reconstructed from the DB alone.

The AND "role" = 'User' predicate does not satisfy this. It constrains which rows are touched, but leaves no durable record of when the change happened, and no way to tell afterwards whether a given row was affected at all. user.role is a plain mutable snapshot column (user.entity.ts:52), and there is no role-history table or audit subscriber elsewhere in the repo that would close the gap.

On the precedent cited in the description: migration/1782222649737-RotateDebugRole.js was merged 2026-06-25, whereas the CRITICAL rule was introduced 2026-07-14 (commit 52f987f22). It is a precedent for role migrations, but not for conformance with the rule as it stands today — and it carries neither an environment gate nor a role-guard on its first statement.

A wertfrei audit row (migration name, timestamp, affected row count, user id) would satisfy the "when" and "to which" halves without any sensitive content.

Minor

1. down() is not a global inverse — :41. It demotes any row currently holding Support, while up() only ever promotes rows holding User. If the address already had Support before up() ran, up() is a no-op but is still recorded as executed, and a later down() removes a role it never granted. (A change to Compliance in between is correctly left alone.)

2. The specs do not pin the "only" semantics they claim — spec.ts:57 and :76. Address, target role and source role are each asserted with independent toContain calls. A mutant using WHERE <address> OR <role> instead of AND would pass both tests while matching every User row. Asserting the predicate as one contiguous fragment, or exercising the SQL against a throwaway Postgres (the repo has that pattern — MIGRATION_TEST_PG, e.g. add-custody-order-completed-at.migration.spec.ts), would close this.

3. The two "when ENVIRONMENT is not dev" tests only cover 'prd'spec.ts:24 and :34. A gate that skipped prd but executed under loc, stg or an unset value would leave both green. The production code is correctly fail-closed (!== 'dev'); it is the assertion that is narrower than its title. A table-driven case over several non-dev values pins the whole space.

4. The grant is considerably broader than the stated measurement purpose — :26. Support is genuinely the weakest role that reaches the dashboard endpoints (RoleGuard accepts the role or its super-roles, role.guard.ts:34,50), so the choice of role is right. But the same role also gates full user search and detail views, support notes and templates, mutating user/account/KYC endpoints, and owner-independent transaction detail. For a performance measurement on a handful of GETs, that is a lot of surface to hand out permanently on an environment whose data is broadly accessible. Worth considering whether the grant should be time-boxed or reverted right after the measurement, rather than left in place.

Checked and fine

  • The environment gate is correct. Environment.DEV = 'dev' (config.ts:35) and the !== 'dev' comparison matches the established pattern in 1784400000000-ActivateBankFrick.js:46 and 1784799156042-ReduceLmActivationDelay.js:58. The gate is one-sided fail-closed: any value other than exactly 'dev' returns early.
  • No production data is touched. Beyond the gate itself, production runs ENVIRONMENT=prd, migrations execute in the same container as the app, and no CI path migrates against prod.
  • up() is state-idempotent, and LOWER() on an EVM checksum address is the right call.
  • Migration conventions, spec placement, the require path depth and Jest collection all match the repo.
  • The lower timestamp is harmless. Since the description was written, 1785400000000-AddFinancialLogQueryIndex.js landed on develop, so 1785311300000 is no longer the highest. That has no effect here: TypeORM determines pending migrations by name, not by comparing against the highest executed timestamp (MigrationExecutor.js:143), so this still runs on the next boot — just after the newer one. The two touch disjoint tables. Only the claim in the description is now stale.

Two wording points in the description

  • "every GET /v1/support/issue/* endpoint returns 403" is broader than the code: GET /support/issue requires only ACCOUNT, and GET /:id plus the file route use optional JWT auth (support-issue.controller.ts:89,177,224). The RoleGuard(SUPPORT) claim holds for the internal dashboard routes (:96, :188).
  • "a mail login always yields an Account token" holds for an address still carried as User, but getMailLoginUser() searches all users of the account (user-data.entity.ts:720) — so it is only unconditional if no other active staff wallet user exists on that account.

CONTRIBUTING requires that an overwritten value stay reconstructible from
the database: when a field changed, from which value, and to which. The
WHERE predicate only pinned the previous value, leaving no record of when
the migration ran or whether it matched a row at all.

Both directions now run the UPDATE and the audit insert as a single
statement, so a failing audit write aborts the role change (fail-closed).
The row is written even when no user matched, which is what makes "ran and
changed nothing" distinguishable from "never ran". It carries the migration
name, direction, affected count, affected user ids and both roles, and no
address or other personal data.

down() is now a true inverse: it only demotes when the up() audit row
reports a non-zero affected count, so a wallet that already held Support
before this migration keeps it.
The previous spec asserted address, target role and source role as three
separate toContain calls, so a predicate using OR instead of AND would have
passed while matching every User row. The conjunction is now asserted as one
contiguous fragment, and the suite runs the migration against a throwaway
Postgres schema, following the MIGRATION_TEST_PG pattern already used by the
custody and realunit migration specs.

The behavioural cases cover what string matching cannot: a second User
address stays untouched, a target address holding Compliance is left alone,
the audit row carries the right affected count, down() after up() restores
User, down() without a promoting up() leaves an existing Support role in
place, and the address match is case-insensitive.

The environment gate is now table-driven over prd, stg, loc, the empty
string and an unset value, instead of asserting "not dev" while only ever
setting prd.
…head

develop has moved on to 1785460000000 since this branch was opened, so the
migration was back-dated. TypeORM would still run it, but the immutability
check blocks any rename once the file is merged, so the timestamp can only
be corrected now.

Renames the file, the class, the name property, the migration name carried
in both audit log rows and the spec's require path and assertion.
The migration specs have used MIGRATION_TEST_PG since #4418 without it
appearing in .env.example, so a fresh checkout gives no hint that the
real-Postgres suites exist or why they skip. This branch adds another such
spec, so the gap is documented here.
AddTradingOrderCreatedIndex landed on develop carrying 1785470000000, the
same prefix this migration had, which leaves the order between the two
undefined. ClearDevUserSignatures has since raised the highest timestamp on
develop to 1785500000000.

Moves to 1785550000000, following the rounded values develop uses rather
than a real creation time, and renames the class, the name property, the
migration name in both audit rows and the spec's require path and assertion.
…list

Each of the ten non-dev cases carried its own set() closure, which spent
eighty lines on what is a list of five values used twice. ClearDevUserSignatures
writes the same table as it.each over the values themselves, so this follows
the spec that sits next to it on develop.

%p rather than %s, because the set here includes the empty string, and %s
would render that case and an unset value as indistinguishable titles.

No assertion changes: the contiguous AND-fragment checks, the remaining SQL
assertions and the Postgres block are untouched, and the count stays at 18.
Against the migration without the audit row 5 of them fail; the OR mutant in
up() (3), the OR mutant in down() (2), LOWER() dropped in both directions (3),
the down() ownership guard removed (2), affectedCount pinned to 0 (2), the
environment gate inverted (17) and the single statement split into two
queries (3) each turn the suite red.
The two comparable audit migrations on develop take the log system from the
entity they mutate: FixFiatOutputValutaDateSerials writes 'FiatOutput' for
fiat_output, ClearDevUserSignatures writes 'User' for user. This one wrote
'Auth', which appears nowhere else as a log system - the only two occurrences
in the repo are a Swagger tag and a notification type.

Moves both audit rows and the down() ownership guard to 'User'. The guard
matters: it looks up the up() row by system, so leaving it on 'Auth' while
up() wrote 'User' would keep down() from ever restoring the role. The
down()-after-up() case covers that coupling and fails if the two drift apart.

No change in blast radius either way, since every reader of the log table
scopes to system 'LogService' and the cleanup job runs per configured pair.
Pinning the value to 'Wrong' fails the audit-row test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants