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,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>
|
||||
Reference in New Issue
Block a user