Skip to content

uid: T-203 title: Money-movement authorization + audit trail (payment endpoints fail closed, every payment on the record) status: done area: bank-access created: 2026-08-28 updated: 2026-08-28 owner: girafeev1 assignee: Bank Access (Diagnosticcs) related: T-167, I-039


T-203 β€” Payment authorization + audit trail

Why (owner, 2026-08-28)

"I've always been concerning about the lack of identity verification and security of it, since there's no additional login required, and there're nothing tracking who's logged in and performed what action exactly… Does Airwallex provide any solution in this API access foundation for account specific login, or does it really just let the web app hold a token for access we configure? If so, do you think using authenticators would be one way to go?"

Owner then directed: "Proceed with 1 & 2" β€” the permission gate and the audit trail, with step-up MFA (item 3) deliberately deferred.

The constraint that shapes this (researched 2026-08-28, Airwallex docs)

Airwallex API auth is a machine credential, not a human one: client_id + API key β†’ a 30-minute access token, belonging to the organization, not a person. Their docs never address per-user attribution for API actions because there is none β€” every API-created transfer is simply "created via API" in their ledger. Their human-identity controls (user roles, sign-in security, the transfer approval workflow) govern web-app users only.

Therefore identity is structurally ours to enforce. Airwallex authenticates the application; only we can authenticate the person. This is the correct division, not a defect β€” but it means the app must carry the whole burden, and before this task it carried none of it.

Relevant because the owner relaxed "require API-initiated transfers to go through the approval workflow" during T-167: the human gate was deliberately moved from Airwallex's side to ours. That trade is only sound if our side is actually strong.

What shipped

1. Authorization β€” payments fail closed. canMoveMoney(role, status) in lib/rbac/permissions.ts, gating /transfers/create and /transfers/queue. Deliberately independent of RBAC_ENABLED: canPerform short-circuits to true while enforcement is rolled out, a sane default for ordinary features but never for a payment endpoint β€” the fallback state must not be "any signed-in session can move money."

Queuing is gated identically to sending, because a queued payment fires for real on signature β€” gating only /create would leave the open door.

2. Audit β€” every money movement on the record. bank_transfer added to AuditEntity (+ admin audit-viewer label, which the type gate caught). Audited: direct send, queue-for-signature, signature-triggered release, release failure, and refusals. Entries carry actor, role, amount, currency, beneficiary, reference, idempotency request_id, resulting transfer id and Airwallex status, plus IP / user-agent / path β€” queryable and exportable from /admin/audit.

The unattended release has no request context, so it is attributed to the creator who authorized those exact amounts, with the triggering signature recorded alongside; createdByRole is now stored on the queue entry to make that possible.

Verification

  • All 5 existing accounts hold a qualifying role (super_admin Γ—1, admin Γ—2, auditing_accountant Γ—2), so no lockout risk β€” confirmed against Firestore before writing a fail-closed gate. Two of them are suspended and are now correctly refused.
  • Session role/status are populated in production β€” confirmed independently by live audit entries carrying role=super_admin.
  • Audit pipeline confirmed live and writing; before this change it held zero bank-related entries.
  • Gate: fresh npm ci + tsc --noEmit --incremental false, clean.

Follow-ups (owner-gated β€” do NOT act without a command)

  • Step-up MFA (TOTP) at the confirm step. The real remaining layer: proves who, where the gate proves whether allowed and the audit log proves what happened. Needs enrollment UI, secret storage, recovery codes, re-prompt window. Owner deferred pending a design proposal.
  • Role membership question for the owner: bank_accounts:write is currently held by bookkeeper and auditing_accountant as well as the admin tier. Enforced as-is here rather than silently redesigning the role model β€” worth deciding whether those two roles should be able to move money.
  • Airwallex-side (owner action, their dashboard): replace the admin API key with a scoped key (Transfers Write + needed reads) β€” Airwallex explicitly discourages admin keys for new integrations; and rotate the key. IP allowlisting is supported by Airwallex but likely unavailable to us (Vercel serverless has no stable egress IPs outside Enterprise Secure Compute) β€” verify before promising it.
  • Activation requires a production deploy (🚷 owner-commanded only).

Log

  • 2026-08-28 β€” Opened and shipped from the owner's security question + "Proceed with 1 & 2". Researched the Airwallex side before touching code (no per-user API identity exists, by design). Diagnosis recorded in I-039. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4