I-039 β Payment endpoints: no permission gate, no audit trail¶
β Attestation: read AGENTS.md Β· board checked by scope (no open T/I covers payment authorization; T-167 built the payment surface, this hardens it) Β· escalated to T-203 (structural security work). Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4
Symptom¶
Owner (2026-08-28), reviewing the Airwallex integration's security posture: "there's no additional login required, and there's nothing tracking who's logged in and performed what action exactly." Investigation confirmed both halves β the second one literally.
Findings¶
1. No authorization on the money endpoints. pages/api/airwallex/transfers/create.ts β the
route that creates a real outbound payout β required only a valid signed-in session. No role check,
no permission check, no subsidiary check (the statement-sync route beside it does check
canAccessSubsidiary). getOrCreateToken(userId) is not a gate either: it auto-authenticates with
the shared org credentials for whatever user id it is handed. /transfers/queue was equally open.
bank_accounts:write already existed in the permission catalog and was assigned to four roles
(super_admin, admin, auditing_accountant, bookkeeper) β and was never checked anywhere.
Designed, never enforced.
Not internet-exposed (a valid staff login is required), so the exposure is insider / stolen-session
rather than an open door. All 5 current accounts already hold a qualifying role, so the gate changes
nothing for them today β its value is structural: a future project_admin, pending, or suspended
account can no longer move money.
2. No durable record of who moved money. The app has a real audit system
(aote-system/auditLogs β actor, role, IP, user-agent, request path, before/after changes) wired
into invoices, projects, approvals, accounting periods and RBAC changes. The payment routes wrote a
console.log instead, which lands in Vercel's rolling runtime logs: not durable, not queryable, not
exportable. Verified empirically: the collection's most recent entries are invoice/project/auth β
zero bank-related entries ever.
Fix (T-203)¶
canMoveMoney(role, status)(lib/rbac/permissions.ts) β deliberately independent ofRBAC_ENABLED.canPerformshort-circuits totruewhile enforcement is being rolled out, which is fine for ordinary features but must never be the fallback for a payment endpoint. Fails closed on the caller's real role + active status.- Gate applied to both
/transfers/createand/transfers/queueβ queuing is deferred money movement (it fires for real on signature), so gating only/createwould leave the open door. bank_transferadded toAuditEntity(+ label in the admin audit viewer), and every money-movement action now audited: direct send, queue-for-signature, signature-triggered release, release failure, and refusals (a denied attempt on the money endpoint belongs on the record).- 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;
createdByRoleis now stored on the queue entry to make that attribution possible.
Audit writes are best-effort by construction (createAuditLog swallows its own failures), so
logging can never strand a payment mid-release.
Not covered (owner-gated, see T-203)¶
Step-up MFA (TOTP) at the confirm step β the next layer, deliberately sequenced after these two. Airwallex-side hardening (scoped API key replacing the admin key, key rotation) is an owner action in their dashboard.
Log¶
- 2026-08-28 β Found while answering the owner's question about Airwallex API identity; researched
the Airwallex side first (their API credential is an org-level machine credential with no per-user
attribution by design, so identity is necessarily ours to enforce). Fixed both gaps; the type gate
caught a
Record<AuditEntity, string>in the admin audit viewer that needed the new label. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4