Skip to content
Merged
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
82 changes: 82 additions & 0 deletions migration/1785584840000-BackfillStaffVerifiedNames.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* PRD-only backfill: sets `verifiedName` on the staff/service accounts that were gated out when the
* staff-clearance rule stopped requiring a KYC level (api#4395 → #4572). No plaintext personal name
* lives in this file: the human account's verified name is read from the PRD deployment variable
* STAFF_VERIFIED_NAME_375162; the service account carries the non-personal designation 'GSheet'.
* The deployment variable is mandatory on PRD so TypeORM cannot record a partial/no-op migration when
* it is missing. The update is idempotent (only touches a still-null verifiedName) and coupled to a
* durable before/after audit entry. Guarded to prd; a no-op elsewhere.
* @class @implements {MigrationInterface}
*/
module.exports = class BackfillStaffVerifiedNames1785584840000 {
name = 'BackfillStaffVerifiedNames1785584840000';

async up(queryRunner) {
if (process.env.ENVIRONMENT !== 'prd') return;

const humanName = process.env.STAFF_VERIFIED_NAME_375162?.trim();
if (!humanName) throw new Error('STAFF_VERIFIED_NAME_375162 is required for the PRD staff-name backfill');
// Array.of avoids looking like MSSQL bracket quoting to the repository's migration syntax guard.
const queryParameters = Array.of(humanName);

await queryRunner.query(
`WITH "targets" AS (
SELECT 375162 AS "id", $1::varchar AS "nextVerifiedName"
UNION ALL
SELECT "userDataId", 'GSheet'::varchar
FROM "user"
WHERE address = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D'
),
"affected" AS (
SELECT ud."id", ud."verifiedName" AS "previousVerifiedName", t."nextVerifiedName"
FROM "user_data" ud
JOIN "targets" t ON t."id" = ud."id"
WHERE ud."verifiedName" IS NULL
FOR UPDATE OF ud
),
"audit" AS (
INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message")
SELECT now(), now(), 'User', 'StaffVerifiedNameBackfill', 'Info',
json_agg(json_build_object(
'userDataId', "id",
'previousVerifiedName', "previousVerifiedName",
'nextVerifiedName', "nextVerifiedName"
) ORDER BY "id")::text
FROM "affected"
HAVING count(*) > 0
RETURNING 1
)
UPDATE "user_data" ud
SET "verifiedName" = a."nextVerifiedName", "updated" = now()
FROM "affected" a
WHERE ud."id" = a."id" AND EXISTS (SELECT 1 FROM "audit")`,
queryParameters,
);

const [{ humanCount, serviceCount }] = await queryRunner.query(
`SELECT
(SELECT count(*)::int FROM "user_data"
WHERE "id" = 375162 AND "verifiedName" = $1) AS "humanCount",
(SELECT count(*)::int
FROM "user" u
JOIN "user_data" ud ON ud."id" = u."userDataId"
WHERE u."address" = '0x791D0AeC86EE6a86d260543ECD57d7932A7fec2D'
AND ud."verifiedName" = 'GSheet') AS "serviceCount"`,
queryParameters,
);

if (Number(humanCount) !== 1 || Number(serviceCount) !== 1) {
throw new Error('PRD staff-name backfill did not reach the required state for both target accounts');
}
}

async down() {
// No-op: a granted clearance is not auto-revoked here; removal requires a separate reviewed,
// audited revocation so an unrelated rollback cannot silently erase an identity/operator grant.
}
};
9 changes: 5 additions & 4 deletions src/shared/auth/role.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ class RoleGuardClass implements CanActivate {
const user = context.switchToHttp().getRequest().user;
if (!this.entryRoles.some((entryRole) => hasRoleAccess(entryRole, user?.role))) return false;

// Elevated endpoint: an identified natural person must be behind the account (see KycGatedRoles).
// The gate is a property of the ENDPOINT, not of the caller, so it applies only when EVERY entry
// role is gated — a gate that also admits e.g. UserRole.USER is an ordinary endpoint that an admin
// happens to reach through the role hierarchy, and must not start demanding staff KYC.
// Elevated endpoint: the account must carry an identity-verified name or an operator-reviewed
// service designation (see KycGatedRoles). The gate is a property of the ENDPOINT, not of the
// caller, so it applies only when EVERY entry role is gated — a gate that also admits e.g.
// UserRole.USER is an ordinary endpoint that an admin happens to reach through the role hierarchy,
// and must not start demanding staff KYC.
//
// Throws rather than returning false: a bare false becomes the generic "Forbidden resource", which
// a caller cannot tell apart from a removed role, so neither staff nor tooling would learn that the
Expand Down
7 changes: 4 additions & 3 deletions src/shared/auth/staff-kyc-clearance.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Staff KYC clearance ALLOWlist — the inverse of the JWT denylists in ProcessService. Elevated
// endpoints (every `RoleGuard` whose entry roles are all in `KycGatedRoles`) require, on top of the
// role, that an identified natural person is behind the calling account: a non-empty `verifiedName`.
// That name is only ever set by an identity-verified path or a reviewed migration, never self-service,
// so it is the authoritative identification signal on its own — no KYC level is required.
// role, a non-empty `verifiedName`. For personal accounts this is an identity-verified natural-person
// name; an operator-reviewed service account may instead carry a non-personal designation. The value
// is only ever set by an identity-verified path or a reviewed migration, never self-service, so it is
// the authoritative clearance signal on its own — no KYC level is required.
// `StaffKycClearanceService` derives the cleared account (user data) ids from the DB into the
// `staffKycClearance` setting; `ProcessService` primes this Set from it, so revoking a staff member's
// clearance takes effect on live tokens within one refresh interval — no re-login, no JWT-secret rotation.
Expand Down
11 changes: 6 additions & 5 deletions src/shared/auth/user-role.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ export enum UserRole {
// Priority-ordered (highest privilege first) for mail-login role resolution.
export const StaffRoles = [UserRole.COMPLIANCE, UserRole.SUPPORT, UserRole.REALUNIT];

// Entry roles that mark an endpoint as elevated: reaching it requires an identified natural person
// behind the account, on top of the role itself. `RoleGuard` therefore demands staff KYC clearance
// (a non-empty `verifiedName`, see `HasStaffKycClearance`) whenever every entry role of a gate is
// listed here. Distinct from `StaffRoles` above, which is about mail-login
// role resolution — this list is about endpoint sensitivity and also covers ADMIN and DEBUG.
// Entry roles that mark an endpoint as elevated: reaching it requires an identity-verified personal
// name or an operator-reviewed service designation on the account, on top of the role itself.
// `RoleGuard` therefore demands staff KYC clearance (a non-empty `verifiedName`, see
// `HasStaffKycClearance`) whenever every entry role of a gate is listed here. Distinct from
// `StaffRoles` above, which is about mail-login role resolution — this list is about endpoint
// sensitivity and also covers ADMIN and DEBUG.
//
// Not listed, deliberately: BANKING_BOT and CUSTODY are non-staff entry roles and stay ungated, so a
// cleared-role holder reaching those endpoints via the `additionalRoles` hierarchy is not KYC-gated.
Expand Down
Loading