Files
Crussell/backend/internal/adminnotify/adminnotify.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

78 lines
3.8 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 that still need the fold (coordination notes):
//
// - handlers/payments/sweep.go:1556 insertCriticalPaymentNotification (money
// agent) — its INSERT ... SELECT ... WHERE NOT EXISTS is the same
// unbounded-across-accounts shape; fold `AND (SELECT COUNT(*) FROM
// admin_notifications _an WHERE _an.reason = 'critical_payment_log' AND
// _an.acknowledged_at IS NULL) < $N` (N = MaxUnacknowledgedCriticalLogs)
// into its WHERE clause.
// - handlers/scheduling/time-blockers.go:511 insertSquareCleanupCriticalNotification
// and internal/jobs/cleanup.go:333 ScanCriticalPaymentLogs — the same
// 'critical_payment_log' insert shape on the GDPR-cleanup and log-scan
// paths (owner: the jobs/scheduling agent).
// - 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
}