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
+386
View File
@@ -0,0 +1,386 @@
<script setup lang="ts">
// Tier 2 Block G — security controls for one event.
//
// Three concerns share this card because they're conceptually one
// surface ("tune the fraud detector for this event"):
// 1. Thresholds — what score counts as medium/high/block.
// 2. Allowlist — CIDRs that bypass scoring entirely.
// 3. Feedback inbox — host verdicts on past flagged accesses (the
// seed corpus for the future ML model).
//
// Reads are viewer+; writes (PUT/POST/DELETE) are editor+. The server
// enforces the role gate; this component just hides write affordances
// from viewers so the buttons don't promise something a click would
// reject.
interface Thresholds {
medium: number
high: number
block: number
defaults: { medium: number; high: number; block: number }
}
interface AllowlistEntry {
event_id: string
cidr: string
label?: string
created_at: string
}
interface FeedbackEntry {
access_log_id: string
verdict: 'legitimate' | 'suspicious'
note?: string
created_at: string
}
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
// --- state ---
const loading = ref(true)
const error = ref<string | null>(null)
const saving = ref(false)
const thresholds = ref<Thresholds>({ medium: 30, high: 60, block: 85, defaults: { medium: 30, high: 60, block: 85 } })
const allowlist = ref<AllowlistEntry[]>([])
const feedback = ref<FeedbackEntry[]>([])
// Inline-add form for new CIDR rows.
const newCIDR = ref('')
const newLabel = ref('')
const addingCIDR = ref(false)
// Toast — same shape as the BrandingCard toast.
type Toast = { kind: 'success' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 4000) {
toast.value = t
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast.value = null }, ms)
}
async function refresh() {
loading.value = true
error.value = null
try {
const [th, al, fb] = await Promise.all([
useApi<Thresholds>(`/events/${props.eventId}/security/thresholds`),
useApi<{ entries: AllowlistEntry[] }>(`/events/${props.eventId}/security/allowlist`),
useApi<{ feedback: FeedbackEntry[] }>(`/events/${props.eventId}/security/feedback`),
])
thresholds.value = th
allowlist.value = al.entries || []
feedback.value = fb.feedback || []
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load security settings')
} finally {
loading.value = false
}
}
onMounted(refresh)
// --- thresholds ---
// Keep the three sliders mutually ordered: dragging medium past high
// pushes high; same for high past block. Without this the host can
// momentarily produce an invalid ordering and submit it — server
// would 400 but the form feels broken.
function clampOrder(which: 'medium' | 'high' | 'block') {
if (which === 'medium' && thresholds.value.medium > thresholds.value.high) {
thresholds.value.high = thresholds.value.medium
}
if (which === 'high') {
if (thresholds.value.high < thresholds.value.medium) thresholds.value.medium = thresholds.value.high
if (thresholds.value.high > thresholds.value.block) thresholds.value.block = thresholds.value.high
}
if (which === 'block' && thresholds.value.block < thresholds.value.high) {
thresholds.value.high = thresholds.value.block
}
}
async function saveThresholds() {
saving.value = true
try {
await useApi(`/events/${props.eventId}/security/thresholds`, {
method: 'PUT',
body: {
medium: thresholds.value.medium,
high: thresholds.value.high,
block: thresholds.value.block,
},
})
showToast({ kind: 'success', text: 'Thresholds saved.' })
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not save thresholds') })
} finally {
saving.value = false
}
}
function resetThresholds() {
thresholds.value.medium = thresholds.value.defaults.medium
thresholds.value.high = thresholds.value.defaults.high
thresholds.value.block = thresholds.value.defaults.block
}
// --- allowlist ---
async function addCIDR() {
if (!newCIDR.value.trim()) return
addingCIDR.value = true
try {
await useApi(`/events/${props.eventId}/security/allowlist`, {
method: 'POST',
body: { cidr: newCIDR.value.trim(), label: newLabel.value.trim() },
})
newCIDR.value = ''
newLabel.value = ''
showToast({ kind: 'success', text: 'Allowlist entry added.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add allowlist entry') })
} finally {
addingCIDR.value = false
}
}
async function removeCIDR(entry: AllowlistEntry) {
if (!confirm(`Remove allowlist entry ${entry.cidr}?`)) return
try {
await useApi(
`/events/${props.eventId}/security/allowlist?cidr=${encodeURIComponent(entry.cidr)}`,
{ method: 'DELETE' },
)
showToast({ kind: 'success', text: 'Allowlist entry removed.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not remove allowlist entry') })
}
}
function fmtDate(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
</script>
<template>
<section class="card">
<header class="mb-3 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Security</h2>
<p class="text-xs text-zinc-500">
Tune the fraud detector for this event.
</p>
</div>
</header>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<p v-if="loading" class="text-sm text-zinc-500">Loading security settings</p>
<div v-else class="space-y-8">
<!-- Thresholds -->
<div>
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Risk thresholds</h3>
<p class="text-xs text-zinc-500">
Scores at or above each cut-off land in that band. Defaults work for most events.
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
type="button"
class="text-xs text-zinc-400 hover:text-zinc-200"
@click="resetThresholds"
>Reset to defaults</button>
<button
v-if="canEdit"
type="button"
class="btn-primary text-sm"
:disabled="saving"
@click="saveThresholds"
>{{ saving ? 'Saving…' : 'Save thresholds' }}</button>
</div>
</div>
<!-- Visual band ribbon: each segment width matches its threshold
range. Gives the host an at-a-glance read of where their
cut-offs sit, instead of three abstract numbers. -->
<div class="mb-4 h-2 w-full overflow-hidden rounded-full bg-zinc-900">
<div class="flex h-full w-full">
<div :style="{ width: thresholds.medium + '%' }" class="bg-brand-700"></div>
<div :style="{ width: (thresholds.high - thresholds.medium) + '%' }" class="bg-amber-700"></div>
<div :style="{ width: (thresholds.block - thresholds.high) + '%' }" class="bg-orange-700"></div>
<div :style="{ width: (100 - thresholds.block) + '%' }" class="bg-red-700"></div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-brand-500 mr-1.5"></span>Medium</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.medium }}</span>
</span>
<input
v-model.number="thresholds.medium"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-brand-500"
@input="clampOrder('medium')"
/>
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-amber-500 mr-1.5"></span>High</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.high }}</span>
</span>
<input
v-model.number="thresholds.high"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-amber-500"
@input="clampOrder('high')"
/>
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-red-500 mr-1.5"></span>Block</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.block }}</span>
</span>
<input
v-model.number="thresholds.block"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-red-500"
@input="clampOrder('block')"
/>
</label>
</div>
</div>
<!-- Allowlist -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">IP allowlist</h3>
<p class="mb-3 text-xs text-zinc-500">
Networks that bypass scoring. Use for office Wi-Fi, the venue's
guest network, or a family router. Accepts a single IP or a CIDR
range. Score is forced to 0 for matching requests.
</p>
<div v-if="canEdit" class="mb-3 flex items-end gap-2">
<div class="flex-1">
<label class="label">CIDR</label>
<input
v-model="newCIDR"
type="text"
placeholder="203.0.113.0/24 or 10.0.0.42"
class="input font-mono text-sm"
/>
</div>
<div class="flex-1">
<label class="label">Label (optional)</label>
<input
v-model="newLabel"
type="text"
placeholder="Office Wi-Fi"
class="input text-sm"
/>
</div>
<button
type="button"
class="btn-primary text-sm"
:disabled="addingCIDR || !newCIDR.trim()"
@click="addCIDR"
>{{ addingCIDR ? 'Adding' : 'Add' }}</button>
</div>
<ul v-if="allowlist.length" class="space-y-1.5">
<li
v-for="entry in allowlist"
:key="entry.cidr"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="font-mono text-sm text-zinc-100">{{ entry.cidr }}</p>
<p class="text-xs text-zinc-500">
{{ entry.label || 'no label' }} · added {{ fmtDate(entry.created_at) }}
</p>
</div>
<button
v-if="canEdit"
type="button"
class="text-xs text-zinc-400 hover:text-red-300"
@click="removeCIDR(entry)"
>Remove</button>
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No allowlist entries yet.</p>
</div>
<!-- Feedback inbox — read-only summary; the actual "mark legitimate / suspicious"
lives on the live monitor / activity feed where the host sees the
access in context. This is the audit trail. -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">Verdict history</h3>
<p class="mb-3 text-xs text-zinc-500">
Your previous "legitimate" / "suspicious" marks on flagged accesses. Seeds the model and silences known false positives.
</p>
<ul v-if="feedback.length" class="space-y-1.5">
<li
v-for="f in feedback.slice(0, 10)"
:key="f.access_log_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
>
<div class="min-w-0 flex-1">
<p class="text-zinc-200">
Marked <strong :class="f.verdict === 'legitimate' ? 'text-brand-300' : 'text-red-300'">{{ f.verdict }}</strong>
<span class="ml-1 text-xs text-zinc-500">· {{ fmtDate(f.created_at) }}</span>
</p>
<p v-if="f.note" class="truncate text-xs text-zinc-500">{{ f.note }}</p>
</div>
</li>
<li v-if="feedback.length > 10" class="text-xs text-zinc-500">
and {{ feedback.length - 10 }} more.
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No verdicts yet.</p>
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<button
v-if="toast"
type="button"
class="fixed bottom-6 right-6 z-50 flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
:class="toast.kind === 'success'
? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
: 'border-red-800/60 bg-red-950/90 text-red-100'"
role="status"
@click="toast = null"
>{{ toast.text }}</button>
</Transition>
</Teleport>
</section>
</template>