Files
bank/hooks/accounts.go
zeekay f99ab6a225 security: fix 12 red team findings (4 critical, 5 high)
CRITICAL:
- F01: Explicit API rules on all collections (owner-scoped list/view)
- F02: HMAC-SHA256 webhook auth replaces RequireSuperuserAuth
- F03: RunInTransaction for atomic balance check+hold (no overdraft)
- F04: Audit log immutable (block update/delete unconditionally)

HIGH:
- F05: math.Round() before all float-to-int casts (no truncation)
- F08: Transfer destination ownership verified
- F09: Fixed daily/monthly limit filter queries
- F12: Added "cancelled" to allowed processing transitions
- F13: Balance floor check (reject if available < 0)

New files: hooks/hmac.go (HMAC middleware), hooks/audit.go (immutable guard)
Env: WEBHOOK_HMAC_SECRET required for webhook endpoints (from KMS)
2026-03-31 16:13:13 -07:00

204 lines
5.8 KiB
Go

package hooks
import (
"log/slog"
"math"
"time"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/luxfi/bank/collections"
)
// Account tier daily/monthly limits in minor units (cents).
var accountLimits = map[string]struct {
daily int64
monthly int64
}{
"individual": {daily: 50_000_00, monthly: 500_000_00}, // $50k / $500k
"business": {daily: 500_000_00, monthly: 5_000_000_00}, // $500k / $5M
}
// RegisterAccountHooks attaches record-level hooks for account management:
// - Block transactions that exceed daily limits
// - Block operations on frozen (suspended) accounts
// - Audit trail on freeze/unfreeze transitions
// - Initialize balance record on account creation
func RegisterAccountHooks(app core.App) {
// Create a default balance record when an account is created.
app.OnRecordCreateExecute(collections.AccountCollectionName).BindFunc(func(e *core.RecordEvent) error {
if err := e.Next(); err != nil {
return err
}
balCollection, err := app.FindCollectionByNameOrId(collections.BalanceCollectionName)
if err != nil {
app.Logger().Error("accounts: balance collection not found", slog.String("error", err.Error()))
return nil
}
bal := core.NewRecord(balCollection)
bal.Set("account", e.Record.Id)
bal.Set("currency", e.Record.GetString("currency"))
bal.Set("available", 0)
bal.Set("held", 0)
if err := app.Save(bal); err != nil {
app.Logger().Error("accounts: failed to create balance",
slog.String("accountId", e.Record.Id),
slog.String("error", err.Error()),
)
}
return nil
})
// Enforce daily limits on transaction creation.
app.OnRecordCreate(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
accountId := e.Record.GetString("account")
if accountId == "" {
return e.Next()
}
direction := e.Record.GetString("direction")
if direction != "debit" {
return e.Next() // limits only on outbound
}
account, err := app.FindRecordById(collections.AccountCollectionName, accountId)
if err != nil {
return err
}
// Check account is not suspended.
if account.GetString("status") == "suspended" {
return apis.NewForbiddenError("account is frozen", nil)
}
entityType := account.GetString("entityType")
limits, ok := accountLimits[entityType]
if !ok {
limits = accountLimits["individual"]
}
amount := int64(math.Round(e.Record.GetFloat("amount")))
// Check daily limit.
dailySpent := getDailySpent(app, accountId)
if dailySpent+amount > limits.daily {
app.Logger().Warn("accounts: daily limit exceeded",
slog.String("accountId", accountId),
slog.Int64("dailySpent", dailySpent),
slog.Int64("amount", amount),
slog.Int64("limit", limits.daily),
)
return apis.NewForbiddenError("daily transaction limit exceeded", nil)
}
// Check monthly limit.
monthlySpent := getMonthlySpent(app, accountId)
if monthlySpent+amount > limits.monthly {
app.Logger().Warn("accounts: monthly limit exceeded",
slog.String("accountId", accountId),
slog.Int64("monthlySpent", monthlySpent),
slog.Int64("amount", amount),
slog.Int64("limit", limits.monthly),
)
return apis.NewForbiddenError("monthly transaction limit exceeded", nil)
}
return e.Next()
})
// Audit trail on account freeze/unfreeze.
app.OnRecordUpdate(collections.AccountCollectionName).BindFunc(func(e *core.RecordEvent) error {
oldStatus := e.Record.Original().GetString("status")
newStatus := e.Record.GetString("status")
if oldStatus == newStatus {
return e.Next()
}
// Only audit freeze/unfreeze transitions.
if (oldStatus == "active" && newStatus == "suspended") ||
(oldStatus == "suspended" && newStatus == "active") {
action := "account_frozen"
if newStatus == "active" {
action = "account_unfrozen"
}
writeAudit(app, e.Record.Id, "system", action, map[string]any{
"previousStatus": oldStatus,
"newStatus": newStatus,
})
}
return e.Next()
})
}
// getDailySpent sums debit transaction amounts for today.
func getDailySpent(app core.App, accountId string) int64 {
records, err := app.FindRecordsByFilter(
collections.TransactionCollectionName,
`account = {:accountId} && direction = "debit" && status != "failed" && status != "cancelled" && created >= @todayStart`,
"",
0,
0,
map[string]any{"accountId": accountId},
)
if err != nil {
return 0
}
var total int64
for _, r := range records {
total += int64(math.Round(r.GetFloat("amount")))
}
return total
}
// getMonthlySpent sums debit transaction amounts for the last 30 days.
func getMonthlySpent(app core.App, accountId string) int64 {
thirtyDaysAgo := time.Now().UTC().AddDate(0, 0, -30).Format("2006-01-02 15:04:05.000Z")
records, err := app.FindRecordsByFilter(
collections.TransactionCollectionName,
`account = {:accountId} && direction = "debit" && status != "failed" && status != "cancelled" && created >= {:since}`,
"",
0,
0,
map[string]any{"accountId": accountId, "since": thirtyDaysAgo},
)
if err != nil {
return 0
}
var total int64
for _, r := range records {
total += int64(math.Round(r.GetFloat("amount")))
}
return total
}
// writeAudit creates an audit_log record.
func writeAudit(app core.App, accountId, actor, action string, detail map[string]any) {
auditCollection, err := app.FindCollectionByNameOrId(collections.AuditCollectionName)
if err != nil {
app.Logger().Error("audit: collection not found", slog.String("error", err.Error()))
return
}
record := core.NewRecord(auditCollection)
record.Set("account", accountId)
record.Set("actor", actor)
record.Set("action", action)
record.Set("detail", detail)
if err := app.Save(record); err != nil {
app.Logger().Error("audit: failed to write",
slog.String("accountId", accountId),
slog.String("action", action),
slog.String("error", err.Error()),
)
}
}