- adminnotify: MaxUnacknowledgedCriticalLogs global cap exposed as CriticalLogsCapExceeded — a pre-check helper every insert site pairs with the atomic fold inside its INSERT (count-then-insert is atomic, closing the TOCTOU where concurrent inserts could both read a below-cap count). - jobs/cleanup.go ScanCriticalPaymentLogs: capped at the shared cap, pre-check skips the scan and logs the suppression. - scheduling: 1_week_no_pay, 1_month_no_pay, default_hours_changed, deposit_not_paid_by_deadline and the Square-erasure critical notification all flood-capped with pre-check + atomic fold (per-booking/per-user dedup kept). - time-blockers.go CleanupExpiredGiftCards (M4): the expiry SELECT now runs under FOR UPDATE row locks so the read-expired-then-zero window is atomic — a concurrent top-up either commits before the SELECT (refreshed last_used_at drops the card out of the predicate) or blocks until the sweep's tx ends and revives the zeroed card via its own expiry refresh; the top-up value can never be destroyed by the sweep. - flood-cap tests added for 1_week_no_pay; adminnotify unit coverage added.
73 lines
3.4 KiB
Go
73 lines
3.4 KiB
Go
// Package adminnotify owns the shared flood cap for the DB-backed admin
|
|
// notification centre (admin_notifications). That table is the single
|
|
// operator's ONLY pager for money events, so every site that inserts an
|
|
// operator-facing alert must bound its unacknowledged queue — a hostile flood
|
|
// (attacker-registered accounts triggering refresh_token_reuse / reissue-fail /
|
|
// webhook alerts) must not be able to bury the notification centre under rows
|
|
// the operator can never work through.
|
|
//
|
|
// Coordination contract (Round 2 Loop B finding 1): the cap is applied
|
|
// atomically at every insert site in this codebase:
|
|
//
|
|
// - handlers/payments/twofa.go — the reissue-fail alert (per-issue capped,
|
|
// see finding 2; NOT globally capped).
|
|
// - auth/jwt.go VerifyRefreshToken — the 'refresh_token_reuse' alert.
|
|
// - handlers/webhooks/square.go — the three critical_payment_log inserts
|
|
// (dispute, booking, unknown-event, orphan-replay).
|
|
// - handlers/user/account.go InsertSquareErasureCriticalNotification.
|
|
//
|
|
// Sites owned by OTHER agents (coordination notes):
|
|
//
|
|
// - handlers/payments/sweep.go insertCriticalPaymentNotification — NOW capped
|
|
// (same atomic fold + pre-check as every other site).
|
|
// - handlers/scheduling/time-blockers.go insertSquareCleanupCriticalNotification
|
|
// and internal/jobs/cleanup.go ScanCriticalPaymentLogs — both NOW capped
|
|
// (same atomic fold + pre-check as every other site).
|
|
// - main.go has NO admin_notifications insert sites (it only mounts the
|
|
// notification read/ack routes), so nothing to cap there.
|
|
//
|
|
// Every site folds the cap INTO its INSERT (a conditional
|
|
// `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the
|
|
// count-then-insert is ATOMIC — closing the TOCTOU where two concurrent
|
|
// inserts both read a below-cap count and overshoot together (finding 2).
|
|
package adminnotify
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
|
|
"crussell/db"
|
|
)
|
|
|
|
// MaxUnacknowledgedCriticalLogs is the GLOBAL cap on unacknowledged
|
|
// admin_notifications rows for one reason (named after the money-critical
|
|
// reason 'critical_payment_log' that the cap exists to protect). Once the
|
|
// unacknowledged queue for a reason reaches the cap, further inserts for that
|
|
// reason are dropped (with a suppression log for operator visibility) until
|
|
// the operator acknowledges outstanding rows. 100 is far beyond anything a
|
|
// single salon produces legitimately, so it only ever suppresses an abnormal
|
|
// flood.
|
|
const MaxUnacknowledgedCriticalLogs = 100
|
|
|
|
// CriticalLogsCapExceeded reports whether the unacknowledged admin-notification
|
|
// queue for reason has reached MaxUnacknowledgedCriticalLogs. q routes through
|
|
// the caller's transaction when one is active (the ctx-routed db.Conn proxy, or
|
|
// an explicit pgx.Tx).
|
|
//
|
|
// Best-effort and fail-OPEN: a count error is logged and false is returned, so
|
|
// a money alert is never dropped because the count query failed (the insert's
|
|
// own atomic cap condition below still guards the row in that case — the
|
|
// pre-check only decides whether to log a suppression).
|
|
func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) bool {
|
|
var n int
|
|
err := q.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM admin_notifications
|
|
WHERE reason = $1::admin_notification_reason AND acknowledged_at IS NULL
|
|
`, reason).Scan(&n)
|
|
if err != nil {
|
|
log.Printf("adminnotify: failed to count unacknowledged %s admin notifications: %v", reason, err)
|
|
return false
|
|
}
|
|
return n >= MaxUnacknowledgedCriticalLogs
|
|
}
|