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>
387 lines
14 KiB
Vue
387 lines
14 KiB
Vue
<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>
|