Skip to content

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 of RBAC_ENABLED. canPerform short-circuits to true while 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/create and /transfers/queue β€” queuing is deferred money movement (it fires for real on signature), so gating only /create would leave the open door.
  • bank_transfer added to AuditEntity (+ 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; createdByRole is 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