feat(tier2): smarter fraud detection — Block G

Per-event fraud tuning. Hosts can now dial the medium / high / block
boundaries, allowlist trusted networks, and feed verdicts back on
flagged accesses — the seed corpus for a future ML model.

Schema (migration 0011)
- events.fraud_{medium,high,block}_threshold default 30/60/85 so
  existing events behave identically until a host changes them
- access_logs.geo_{country,city,lat,lon} for future enrichment
- fraud_feedback table — verdict ('legitimate' | 'suspicious') + note,
  PK on access_log_id so re-mark is an upsert
- event_allowlists table — (event_id, ip_cidr) primary key, inet column
  so containment checks use the native >>= operator (indexed lookup)

Domain
- FraudThresholds with Valid() + Band() helpers; Default trio echoed
  through GET responses so the frontend doesn't duplicate constants
- ParseAllowlistCIDR accepts bare IPs (auto-widens to /32 or /128) and
  canonicalises the output (203.0.113.42 → 203.0.113.42/32)
- Event.Thresholds() falls back to defaults if columns weren't
  populated yet, so the API never wedges every score into "low"

Storage
- AllowlistRepo: List / Add / Remove + Matches() — the latter pushes
  CIDR containment into Postgres rather than streaming rows back
- FeedbackRepo: Record (upserts) + ListForEvent (joined through guests)
- EventRepo.GetThresholds + UpdateThresholds, plus the threshold
  columns baked into scanEvent so every event load carries them
- AccessLogRepo.BelongsToEvent — stops a hostile editor on event A
  from marking event B's access logs

API
- GET/PUT /events/{id}/security/thresholds (viewer/editor)
- GET/POST/DELETE /events/{id}/security/allowlist
- POST /events/{id}/access-logs/{log_id}/feedback (editor)
- GET /events/{id}/security/feedback
- RSVP scoring path: allowlist short-circuit fires before the fraud
  engine; the engine's score is then re-banded against the event's
  thresholds (engine.Risk becomes advisory — API is the source of
  truth for "what counts as block here")
- CORS Allow-Methods already includes PUT (Block D fix)

Fraud engine
- Single-signal cap: it now takes ≥2 sub-scores of ≥70 to push the
  final into HIGH. Fixes the well-known "second visit with a slightly
  shifted fingerprint scores 60+" false positive
- Engine band remains advisory; API re-bands using per-event
  thresholds before deciding to block

Frontend
- SecurityCard.vue: visual band ribbon (proportional to thresholds),
  three sliders with mutual clamping so dragging medium past high
  pushes high (not an invalid ordering), reset-to-defaults button,
  CIDR allowlist with inline add + per-row remove, verdict-history
  inbox. Toast feedback on save/add/remove
- "Security" tab added to the event-detail tab nav (5th tab,
  right of Analytics)
- Viewer role hides write affordances; server enforces too

Tests
- Domain: ThresholdsBand, ThresholdsValid, ParseAllowlistCIDR (bare
  IP widening + traversal/typo rejection), FraudFeedbackValid
- Integration: thresholds round-trip + invalid ordering rejection,
  allowlist CRUD + duplicate 409 + invalid CIDR 400 + IP auto-widen,
  feedback record + upsert + cross-tenant 404 + invalid verdict 400,
  viewer can read / editor can write / outsider gets 404
- Full integration suite green (315.8s, all 36 top-level tests pass)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-19 21:33:57 +01:00
parent e5b187c575
commit b873012191
22 changed files with 1953 additions and 142 deletions
+241
View File
@@ -0,0 +1,241 @@
//go:build integration
package integration_test
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
)
// Tier 2 Block G — per-event thresholds, allowlists, feedback.
type thresholdsResp struct {
Medium int `json:"medium"`
High int `json:"high"`
Block int `json:"block"`
Defaults struct {
Medium int `json:"medium"`
High int `json:"high"`
Block int `json:"block"`
} `json:"defaults"`
}
// TestSecurityThresholdsRoundTrip confirms a fresh event has the default
// 30/60/85 thresholds, a PUT persists, and an invalid ordering is rejected
// at the API rather than reaching the DB.
func TestSecurityThresholdsRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Thresholds", "thresholds-test")
var got thresholdsResp
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID),
token, http.StatusOK, &got)
if got.Medium != 30 || got.High != 60 || got.Block != 85 {
t.Fatalf("defaults: got %+v", got)
}
if got.Defaults.Medium != 30 {
t.Errorf("defaults echo missing: %+v", got)
}
// Tighten the thresholds — a strict event blocks at 70 rather than 85.
putJSON(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token,
map[string]any{"medium": 20, "high": 50, "block": 70}, http.StatusOK, nil)
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID),
token, http.StatusOK, &got)
if got.Medium != 20 || got.High != 50 || got.Block != 70 {
t.Errorf("after PUT: got %+v, want 20/50/70", got)
}
// Invalid ordering: medium > high — rejected.
assertStatus(t, http.MethodPut,
fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token,
map[string]any{"medium": 80, "high": 50, "block": 90}, http.StatusBadRequest)
}
// TestSecurityAllowlistCRUD covers add → list → remove + duplicate
// rejection + invalid CIDR rejection.
func TestSecurityAllowlistCRUD(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Allowlist", "allowlist-test")
// Add a CIDR + a single IP (which we auto-widen to /32).
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
token, map[string]any{"cidr": "203.0.113.0/24", "label": "Office"},
http.StatusCreated, nil)
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
token, map[string]any{"cidr": "10.0.0.42", "label": "Family router"},
http.StatusCreated, nil)
// List.
var list struct {
Entries []struct {
CIDR string `json:"cidr"`
Label string `json:"label"`
} `json:"entries"`
}
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
token, http.StatusOK, &list)
if len(list.Entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(list.Entries))
}
foundOffice := false
foundFamily := false
for _, e := range list.Entries {
if e.CIDR == "203.0.113.0/24" {
foundOffice = true
}
if e.CIDR == "10.0.0.42/32" { // auto-widened
foundFamily = true
}
}
if !foundOffice || !foundFamily {
t.Errorf("entries: %+v", list.Entries)
}
// Duplicate → 409.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token,
map[string]any{"cidr": "203.0.113.0/24"}, http.StatusConflict)
// Invalid CIDR → 400.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token,
map[string]any{"cidr": "not-an-ip"}, http.StatusBadRequest)
// Remove the office one.
assertStatus(t, http.MethodDelete,
fmt.Sprintf("%s/events/%s/security/allowlist?cidr=%s", srv.URL, eventID, "203.0.113.0%2F24"),
token, nil, http.StatusNoContent)
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
token, http.StatusOK, &list)
if len(list.Entries) != 1 {
t.Errorf("after delete: expected 1 entry, got %d", len(list.Entries))
}
}
// TestSecurityFeedbackRecord exercises POST feedback + list. The access
// log id is forged via the DB so we don't need to drive a full RSVP flow
// — only the feedback path is under test here.
func TestSecurityFeedbackRecord(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Feedback", "feedback-test")
// Insert a guest + access log directly so we have an ID to feed back on.
var guestID, accessLogID uuid.UUID
must(t, db.Pool.QueryRow(ctx, `
INSERT INTO guests (event_id, name) VALUES ($1, 'Test')
RETURNING id
`, eventID).Scan(&guestID), "insert guest")
must(t, db.Pool.QueryRow(ctx, `
INSERT INTO access_logs (guest_id) VALUES ($1) RETURNING id
`, guestID).Scan(&accessLogID), "insert access_log")
// Record a verdict.
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID),
token,
map[string]any{"verdict": "legitimate", "note": "Aunty's actually fine"},
http.StatusOK, nil)
// Upsert: change verdict on second call.
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID),
token,
map[string]any{"verdict": "suspicious"},
http.StatusOK, nil)
// List.
var list struct {
Feedback []struct {
AccessLogID uuid.UUID `json:"access_log_id"`
Verdict string `json:"verdict"`
} `json:"feedback"`
}
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/feedback", srv.URL, eventID),
token, http.StatusOK, &list)
if len(list.Feedback) != 1 {
t.Fatalf("expected 1 feedback row after upsert, got %d", len(list.Feedback))
}
if list.Feedback[0].Verdict != "suspicious" {
t.Errorf("verdict not upserted: %+v", list.Feedback)
}
// Cross-tenant: another event's editor can't mark this log.
otherEventID := createEvent(t, srv.URL, token, "Other", "other-feedback")
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, otherEventID, accessLogID),
token,
map[string]any{"verdict": "legitimate"},
http.StatusNotFound)
// Invalid verdict → 400.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID),
token,
map[string]any{"verdict": "kinda-sus"},
http.StatusBadRequest)
}
// TestSecurityAuthzMatrix confirms viewers can read but not write, and
// non-members can't even see the endpoints exist.
func TestSecurityAuthzMatrix(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, ownerID, ownerToken := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, ownerToken, "Authz Sec", "authz-sec")
viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool)
directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID))
// Viewer can read.
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
viewerToken, nil, http.StatusOK)
// Viewer can't write.
assertStatus(t, http.MethodPut,
fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID),
viewerToken, map[string]any{"medium": 25, "high": 55, "block": 80},
http.StatusForbidden)
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID),
viewerToken, map[string]any{"cidr": "10.0.0.0/24"}, http.StatusForbidden)
// Outsider: 404 everywhere.
_, outsiderToken := makeAuthedUser(t, ctx, db.Pool)
assertStatus(t, http.MethodGet,
fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID),
outsiderToken, nil, http.StatusNotFound)
}