- notifications priority ordering: money-critical reasons (webhooks, sweeps, refunds, gift-card, manual-refund failures) above routine - admin notifications page exposes the flood-cap suppressed count - adminnotify.go contract doc: removed stale 2FA reissue-fail site, current insert-site list Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
147 lines
6.7 KiB
Go
147 lines
6.7 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 / 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()
|
|
}
|