// 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 / 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: // // - auth/jwt.go VerifyRefreshToken — the 'refresh_token_reuse' alert. // - handlers/webhooks/square.go — the 'critical_payment_log' inserts // (dispute, booking, unknown-event, orphan-replay). // - handlers/user/account.go InsertSquareErasureCriticalNotification. // - handlers/payments/sweep.go insertCriticalPaymentNotification. // - handlers/scheduling/time-blockers.go InsertSquareCleanupCriticalNotification // and the deposit-deadline cleanup insert. // - internal/jobs/cleanup.go ScanCriticalPaymentLogs. // - handlers/scheduling/scheduled-cleanup.go — the '1_week_no_pay', // '1_month_no_pay' and 'default_hours_changed' inserts. // - handlers/bookings/bookings.go — the 'new_booking' / 'pending_booking' / // 'cancelled_booking' inserts. // - handlers/bookings/manage.go — the 'cancelled_booking' / 'edit_requested' // inserts. // - handlers/payments/refunds.go — the 'refund_failed' insert. // // 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). // Every site ALSO pre-checks CriticalLogsCapExceeded to log the suppression; // that pre-check is the single choke point that records the suppression in // admin_notification_suppressions, so the operator-facing "suppressed this // cycle" count on GET /api/admin/notifications stays accurate without any // insert site having to change. package adminnotify import ( "context" "log" "time" "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). // // At the cap the suppression is ALSO recorded in admin_notification_suppressions // (recordSuppression) so the admin notifications page can surface how many // alerts were dropped. The record write is best-effort too — a failure only // loses the counter, never the boolean decision. 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 } if n >= MaxUnacknowledgedCriticalLogs { recordSuppression(ctx, q, reason) return true } return false } // recordSuppression upserts the per-reason flood-cap suppression counter // (admin_notification_suppressions). Every insert site funnels its pre-check // through CriticalLogsCapExceeded, so a suppression is recorded exactly once // per dropped alert, and a running suppressed_count accumulates while the // reason's unacknowledged queue stays at the cap. Best-effort: a failure is // logged, never propagated — the alert is already being dropped at the cap and // the operator-facing counter is informational, not load-bearing. func recordSuppression(ctx context.Context, q db.Querier, reason string) { if _, err := q.Exec(ctx, ` INSERT INTO admin_notification_suppressions (reason, suppressed_count, first_suppressed_at, last_suppressed_at) VALUES ($1::admin_notification_reason, 1, NOW(), NOW()) ON CONFLICT (reason) DO UPDATE SET suppressed_count = admin_notification_suppressions.suppressed_count + 1, last_suppressed_at = NOW() `, reason); err != nil { log.Printf("adminnotify: failed to record flood-cap suppression for %s: %v", reason, err) } } // Suppression is the per-reason flood-cap suppression counter surfaced to the // operator: how many alerts were dropped for that reason while its // unacknowledged queue sat at MaxUnacknowledgedCriticalLogs. type Suppression struct { Reason string SuppressedCount int LastSuppressedAt time.Time } // ActiveSuppressions returns the per-reason flood-cap suppressions whose // underlying unacknowledged queue is STILL at the cap — i.e. the operator has // not yet worked it down ("this cycle"). The admin notifications handler sums // these into the response's suppressed count; the count naturally resets once // the operator acknowledges the queue back below the cap. Best-effort: the // caller must treat a non-nil error as "no suppression data available". func ActiveSuppressions(ctx context.Context, q db.Querier) ([]Suppression, error) { rows, err := q.Query(ctx, ` SELECT s.reason, s.suppressed_count, s.last_suppressed_at FROM admin_notification_suppressions s WHERE (SELECT COUNT(*) FROM admin_notifications an WHERE an.reason = s.reason AND an.acknowledged_at IS NULL) >= $1 ORDER BY s.last_suppressed_at DESC `, MaxUnacknowledgedCriticalLogs) if err != nil { return nil, err } defer rows.Close() var out []Suppression for rows.Next() { var s Suppression if err := rows.Scan(&s.Reason, &s.SuppressedCount, &s.LastSuppressedAt); err != nil { return nil, err } out = append(out, s) } return out, rows.Err() }