b873012191
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>
242 lines
8.2 KiB
Go
242 lines
8.2 KiB
Go
//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)
|
|
}
|