Pulse/internal/api/auth_denial_signal_test.go
rcourtman ff03168e5e
Some checks failed
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Public docs / check (push) Waiting to run
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Waiting to run
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Has been cancelled
test(api): synchronize auth denial log capture
2026-08-08 01:15:25 +01:00

239 lines
7.9 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// lockedLogBuffer keeps log capture safe when another package goroutine logs
// while an auth-denial test owns the process-wide zerolog logger. bytes.Buffer
// is not safe for concurrent writes or for a read racing a write.
type lockedLogBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedLogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedLogBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// captureAuthDenialLogs swaps the global logger for a buffer at debug level and
// resets the denial tracker, so each case starts from a known window.
func captureAuthDenialLogs(t *testing.T) *lockedLogBuffer {
t.Helper()
var buf lockedLogBuffer
prevLogger := log.Logger
prevLevel := zerolog.GlobalLevel()
log.Logger = zerolog.New(&buf).Level(zerolog.DebugLevel)
zerolog.SetGlobalLevel(zerolog.DebugLevel)
authDenialMu.Lock()
authDenials = make(map[string]*authDenialCounter)
authDenialMu.Unlock()
prevNow := authDenialNow
t.Cleanup(func() {
log.Logger = prevLogger
zerolog.SetGlobalLevel(prevLevel)
authDenialNow = prevNow
authDenialMu.Lock()
authDenials = make(map[string]*authDenialCounter)
authDenialMu.Unlock()
})
return &buf
}
func countLogEvents(t *testing.T, buf *lockedLogBuffer, level, message string) int {
t.Helper()
count := 0
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if line == "" {
continue
}
var event struct {
Level string `json:"level"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode captured log event %q: %v", line, err)
}
if event.Level == level && event.Message == message {
count++
}
}
return count
}
// A non-admin browsing a UI that still mounts an admin-only surface produces a
// steady trickle of refusals. Warning on each is what made #1601's rc.9 reporter
// read a working RBAC gate as a break, so ordinary refusals must stay at debug.
func TestAuthDenialBelowThresholdStaysDebug(t *testing.T) {
buf := captureAuthDenialLogs(t)
req := httptest.NewRequest("GET", "/api/connections", nil)
const message = "Non-admin user attempted to access admin endpoint"
for i := 0; i < authDenialWarnThreshold-1; i++ {
logAuthDenial(req, "viewer", message, nil)
}
if got := countLogEvents(t, buf, "debug", message); got != authDenialWarnThreshold-1 {
t.Fatalf("debug lines = %d, want %d", got, authDenialWarnThreshold-1)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
t.Fatalf("warn lines = %d, want 0 below the probing threshold", got)
}
}
// The signal worth an operator's attention is the rate, not the individual
// refusal — and the escalation must not itself become the new spam.
func TestAuthDenialEscalatesOncePerWindow(t *testing.T) {
buf := captureAuthDenialLogs(t)
req := httptest.NewRequest("GET", "/api/connections", nil)
for i := 0; i < authDenialWarnThreshold*3; i++ {
logAuthDenial(req, "viewer", "Non-admin user attempted to access admin endpoint", nil)
}
output := buf.String()
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
t.Fatalf("warn lines = %d, want exactly 1 for a single window", got)
}
if !strings.Contains(output, "possible endpoint probing") {
t.Fatalf("escalation missing from output: %q", output)
}
}
func TestAuthDenialWindowRolloverRearmsEscalation(t *testing.T) {
buf := captureAuthDenialLogs(t)
req := httptest.NewRequest("GET", "/api/connections", nil)
base := time.Unix(1_700_000_000, 0)
authDenialNow = func() time.Time { return base }
for i := 0; i < authDenialWarnThreshold; i++ {
logAuthDenial(req, "viewer", "denied", nil)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
t.Fatalf("first window warn lines = %d, want 1", got)
}
// A caller that goes quiet and starts probing again later is a fresh signal,
// not a permanently silenced one.
authDenialNow = func() time.Time { return base.Add(authDenialWindow + time.Second) }
for i := 0; i < authDenialWarnThreshold; i++ {
logAuthDenial(req, "viewer", "denied", nil)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 2 {
t.Fatalf("warn lines after rollover = %d, want 2", got)
}
}
// Attribution follows the identity when there is one, so a probing account is
// still caught after rotating source addresses.
func TestAuthDenialTracksIdentityAcrossAddresses(t *testing.T) {
buf := captureAuthDenialLogs(t)
for i := 0; i < authDenialWarnThreshold; i++ {
req := httptest.NewRequest("GET", "/api/connections", nil)
req.RemoteAddr = "10.0.0." + string(rune('1'+i%9)) + ":5000"
logAuthDenial(req, "viewer", "denied", nil)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
t.Fatalf("warn lines = %d, want 1 — identity should aggregate across addresses", got)
}
}
// Separate callers must not pool into one another's budget, or one busy client
// would silence the signal for everybody else.
func TestAuthDenialSeparatesCallers(t *testing.T) {
buf := captureAuthDenialLogs(t)
req := httptest.NewRequest("GET", "/api/connections", nil)
for i := 0; i < authDenialWarnThreshold-1; i++ {
logAuthDenial(req, "viewer-a", "denied", nil)
logAuthDenial(req, "viewer-b", "denied", nil)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 0 {
t.Fatalf("warn lines = %d, want 0 — neither caller crossed the threshold", got)
}
}
// The API package can have background monitor goroutines logging while these
// tests run, and production handlers may record denials concurrently. Exercise
// that concurrency directly so an unsynchronized capture sink or tracker state
// fails deterministically under go test -race.
func TestAuthDenialConcurrentLoggingIsRaceFreeAndWarnsOnce(t *testing.T) {
buf := captureAuthDenialLogs(t)
base := time.Unix(1_700_000_000, 0)
authDenialNow = func() time.Time { return base }
const (
identity = "concurrent-viewer"
message = "concurrent denial"
attempts = authDenialWarnThreshold * 8
)
var wg sync.WaitGroup
wg.Add(attempts)
for i := 0; i < attempts; i++ {
go func() {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/connections", nil)
logAuthDenial(req, identity, message, nil)
}()
}
wg.Wait()
if got := countLogEvents(t, buf, "debug", message); got != attempts {
t.Fatalf("debug lines = %d, want %d", got, attempts)
}
if got := countLogEvents(t, buf, "warn", "Repeated authorization denials from one caller; possible endpoint probing"); got != 1 {
t.Fatalf("warn lines = %d, want exactly 1 for concurrent denials in one window", got)
}
authDenialMu.Lock()
entry := *authDenials["user:"+identity]
authDenialMu.Unlock()
if entry.count != attempts || !entry.warned {
t.Fatalf("counter = {count:%d warned:%t}, want {count:%d warned:true}", entry.count, entry.warned, attempts)
}
}
func TestAuthDenialPruneKeepsTrackedSetBounded(t *testing.T) {
captureAuthDenialLogs(t)
base := time.Unix(1_700_000_000, 0)
authDenialNow = func() time.Time { return base }
for i := 0; i < authDenialMaxTracked+64; i++ {
req := httptest.NewRequest("GET", "/api/connections", nil)
logAuthDenial(req, "spoofed-"+strings.Repeat("x", i%7)+string(rune(i)), "denied", nil)
}
authDenialMu.Lock()
tracked := len(authDenials)
authDenialMu.Unlock()
if tracked > authDenialMaxTracked {
t.Fatalf("tracked callers = %d, want <= %d", tracked, authDenialMaxTracked)
}
}