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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user