feat: build core API, fraud engine, notifier, and frontend
Phase 1 — Core API (Go): - Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5 - HMAC-signed per-guest tokens with format validation - Health endpoint with DB ping, slog JSON logging, graceful shutdown Phase 2 — NATS + Fraud Engine: - NATS JetStream pub/sub with explicit-ack consumers - Python/FastAPI fraud engine with heuristic risk scoring (fingerprint mismatch, IP change, missing signals, repeated access) - gRPC sync scoring with 250ms fail-open timeout - Per-guest baseline tracking; risk bands low/medium/high/block Phase 3 — Notifications + Frontend: - Notification worker scaffolding (Twilio/SES stubs, retry/backoff) - Nuxt 3 frontend with Tailwind dark theme + brand green - Live monitor via WebSocket with auto-reconnect - Activity history endpoint backfills monitor with RSVPs + scored access checks (including blocked attempts) UX polish: - Marketing-friendly landing page (hero mockup, how-it-works, features, use cases, testimonials, FAQ, final CTA) - Animated layered card mockups on landing + new-event page - Plus-ones stepper, RSVP status badges, filter buttons - Friendly access-check labels (Verified/Review/Suspicious/Blocked) - Dashboard hydration fix via ClientOnly wrapper Infrastructure: - docker-compose for full local dev (postgres, nats, api, fraud-engine, notifier, frontend) - Multi-stage Dockerfiles, non-root UID 1000 - Integration tests with testcontainers-go Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="GG_", env_file=".env", extra="ignore")
|
||||
|
||||
env: str = Field(default="development")
|
||||
http_addr: str = Field(default="0.0.0.0:8081")
|
||||
grpc_addr: str = Field(default="0.0.0.0:9091")
|
||||
nats_url: str = Field(default="nats://localhost:4222")
|
||||
stream_name: str = Field(default="GUESTGUARD")
|
||||
consumer_durable: str = Field(default="fraud-engine-access")
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self.http_addr.split(":", 1)[0] or "0.0.0.0"
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
parts = self.http_addr.rsplit(":", 1)
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return 8081
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from nats.aio.msg import Msg
|
||||
|
||||
from app.nats_bus import NatsBus
|
||||
from app.schemas import AccessAttempted, FraudScored
|
||||
from app.scoring import HeuristicScorer, risk_band
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUBJECT_ACCESS_ATTEMPTED = "guest.access.attempted"
|
||||
SUBJECT_FRAUD_SCORED = "fraud.scored"
|
||||
|
||||
|
||||
class FraudConsumer:
|
||||
def __init__(self, bus: NatsBus, durable: str, scorer: HeuristicScorer) -> None:
|
||||
self._bus = bus
|
||||
self._durable = durable
|
||||
self._scorer = scorer
|
||||
self._subscription = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._subscription = await self._bus.subscribe(
|
||||
subject=SUBJECT_ACCESS_ATTEMPTED,
|
||||
durable=self._durable,
|
||||
handler=self._handle,
|
||||
manual_ack=True,
|
||||
)
|
||||
logger.info("subscribed", extra={"subject": SUBJECT_ACCESS_ATTEMPTED, "durable": self._durable})
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._subscription is not None:
|
||||
await self._subscription.unsubscribe()
|
||||
self._subscription = None
|
||||
|
||||
async def _handle(self, msg: Msg) -> None:
|
||||
try:
|
||||
evt = AccessAttempted.model_validate_json(msg.data)
|
||||
except Exception:
|
||||
logger.exception("invalid access.attempted payload — terminating message")
|
||||
await msg.term()
|
||||
return
|
||||
|
||||
try:
|
||||
result = self._scorer.score(evt)
|
||||
scored = FraudScored(
|
||||
event_id=evt.event_id,
|
||||
guest_id=evt.guest_id,
|
||||
token_id=evt.token_id,
|
||||
access_log_id=evt.access_log_id,
|
||||
score=result.score,
|
||||
risk=risk_band(result.score),
|
||||
reasons=result.reasons,
|
||||
scored_at=datetime.now(UTC),
|
||||
)
|
||||
await self._bus.publish(
|
||||
SUBJECT_FRAUD_SCORED,
|
||||
scored.model_dump_json().encode("utf-8"),
|
||||
)
|
||||
logger.info(
|
||||
"scored access",
|
||||
extra={
|
||||
"guest_id": str(evt.guest_id),
|
||||
"score": result.score,
|
||||
"risk": scored.risk,
|
||||
},
|
||||
)
|
||||
await msg.ack()
|
||||
except Exception:
|
||||
logger.exception("failed to score access — nak")
|
||||
await msg.nak(delay=2)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import grpc
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
from app.scoring import BLOCK, HIGH, LOW, MEDIUM, HeuristicScorer, risk_band
|
||||
from fraud.v1 import fraud_pb2, fraud_pb2_grpc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RISK_TO_PROTO = {
|
||||
LOW: fraud_pb2.RISK_LOW,
|
||||
MEDIUM: fraud_pb2.RISK_MEDIUM,
|
||||
HIGH: fraud_pb2.RISK_HIGH,
|
||||
BLOCK: fraud_pb2.RISK_BLOCK,
|
||||
}
|
||||
|
||||
|
||||
class FraudServicer(fraud_pb2_grpc.FraudServiceServicer):
|
||||
def __init__(self, scorer: HeuristicScorer) -> None:
|
||||
self._scorer = scorer
|
||||
|
||||
async def Score( # noqa: N802 — gRPC method
|
||||
self,
|
||||
request: fraud_pb2.ScoreRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> fraud_pb2.ScoreResponse:
|
||||
try:
|
||||
evt = AccessAttempted(
|
||||
event_id=UUID(request.event_id),
|
||||
guest_id=UUID(request.guest_id),
|
||||
token_id=UUID(request.token_id),
|
||||
access_log_id=UUID(request.access_log_id) if request.access_log_id else UUID(int=0),
|
||||
fingerprint=dict(request.fingerprint) if request.fingerprint else None,
|
||||
ip_address=request.ip_address or None,
|
||||
user_agent=request.user_agent or None,
|
||||
referrer=request.referrer or None,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, f"bad request: {exc}")
|
||||
raise # unreachable, abort raises
|
||||
|
||||
result = self._scorer.score(evt)
|
||||
band = risk_band(result.score)
|
||||
return fraud_pb2.ScoreResponse(
|
||||
score=result.score,
|
||||
risk=_RISK_TO_PROTO.get(band, fraud_pb2.RISK_UNSPECIFIED),
|
||||
reasons=result.reasons,
|
||||
)
|
||||
|
||||
|
||||
async def serve_grpc(scorer: HeuristicScorer, addr: str) -> grpc.aio.Server:
|
||||
server = grpc.aio.server()
|
||||
fraud_pb2_grpc.add_FraudServiceServicer_to_server(FraudServicer(scorer), server)
|
||||
server.add_insecure_port(addr)
|
||||
await server.start()
|
||||
logger.info("grpc server started", extra={"addr": addr})
|
||||
return server
|
||||
|
||||
|
||||
async def stop_grpc(server: grpc.aio.Server) -> None:
|
||||
await server.stop(grace=2.0)
|
||||
await asyncio.sleep(0)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import load_settings
|
||||
from app.consumer import FraudConsumer
|
||||
from app.grpc_server import serve_grpc, stop_grpc
|
||||
from app.nats_bus import NatsBus
|
||||
from app.scoring import HeuristicScorer
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
def _configure_logging(env: str) -> None:
|
||||
level = logging.DEBUG if env == "development" else logging.INFO
|
||||
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
_configure_logging(settings.env)
|
||||
|
||||
bus = NatsBus(settings.nats_url, settings.stream_name)
|
||||
await bus.connect()
|
||||
|
||||
scorer = HeuristicScorer()
|
||||
consumer = FraudConsumer(bus, settings.consumer_durable, scorer)
|
||||
await consumer.start()
|
||||
|
||||
grpc_server = await serve_grpc(scorer, settings.grpc_addr)
|
||||
|
||||
app.state.bus = bus
|
||||
app.state.consumer = consumer
|
||||
app.state.scorer = scorer
|
||||
app.state.grpc = grpc_server
|
||||
app.state.settings = settings
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_grpc(grpc_server)
|
||||
await consumer.stop()
|
||||
await bus.close()
|
||||
|
||||
|
||||
app = FastAPI(title="GuestGuard Fraud Engine", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready() -> dict[str, str]:
|
||||
bus = getattr(app.state, "bus", None)
|
||||
if bus is None:
|
||||
return {"status": "starting"}
|
||||
return {"status": "ok", "nats": "up"}
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
settings = load_settings()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import nats
|
||||
from nats.aio.client import Client as NATSClient
|
||||
from nats.js import JetStreamContext
|
||||
from nats.js.api import ConsumerConfig, DeliverPolicy, RetentionPolicy, StorageType, StreamConfig
|
||||
from nats.js.errors import NotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_SUBJECTS = ["guest.>", "fraud.>", "rsvp.>", "invitation.>"]
|
||||
|
||||
|
||||
class NatsBus:
|
||||
def __init__(self, url: str, stream_name: str) -> None:
|
||||
self._url = url
|
||||
self._stream_name = stream_name
|
||||
self._nc: NATSClient | None = None
|
||||
self._js: JetStreamContext | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._nc = await nats.connect(
|
||||
self._url,
|
||||
name="guestguard-fraud-engine",
|
||||
max_reconnect_attempts=-1,
|
||||
reconnect_time_wait=2,
|
||||
)
|
||||
self._js = self._nc.jetstream()
|
||||
await self._ensure_stream()
|
||||
logger.info("connected to nats", extra={"url": self._url})
|
||||
|
||||
async def _ensure_stream(self) -> None:
|
||||
assert self._js is not None
|
||||
try:
|
||||
await self._js.stream_info(self._stream_name)
|
||||
return
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
cfg = StreamConfig(
|
||||
name=self._stream_name,
|
||||
subjects=STREAM_SUBJECTS,
|
||||
retention=RetentionPolicy.LIMITS,
|
||||
storage=StorageType.FILE,
|
||||
max_age=14 * 24 * 60 * 60 * 1_000_000_000, # 14 days, ns
|
||||
)
|
||||
await self._js.add_stream(config=cfg)
|
||||
|
||||
@property
|
||||
def js(self) -> JetStreamContext:
|
||||
if self._js is None:
|
||||
raise RuntimeError("nats not connected")
|
||||
return self._js
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
subject: str,
|
||||
durable: str,
|
||||
handler,
|
||||
manual_ack: bool = True,
|
||||
):
|
||||
cfg = ConsumerConfig(
|
||||
durable_name=durable,
|
||||
ack_policy="explicit",
|
||||
deliver_policy=DeliverPolicy.ALL,
|
||||
max_deliver=5,
|
||||
ack_wait=30,
|
||||
filter_subject=subject,
|
||||
)
|
||||
return await self.js.subscribe(
|
||||
subject=subject,
|
||||
durable=durable,
|
||||
cb=handler,
|
||||
manual_ack=manual_ack,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
async def publish(self, subject: str, payload: bytes) -> None:
|
||||
await self.js.publish(subject, payload)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._nc is not None:
|
||||
await self._nc.drain()
|
||||
await asyncio.sleep(0)
|
||||
self._nc = None
|
||||
self._js = None
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AccessAttempted(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
event_id: UUID
|
||||
guest_id: UUID
|
||||
token_id: UUID
|
||||
access_log_id: UUID
|
||||
fingerprint: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
referrer: str | None = None
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
class FraudScored(BaseModel):
|
||||
event_id: UUID
|
||||
guest_id: UUID
|
||||
token_id: UUID
|
||||
access_log_id: UUID
|
||||
score: int = Field(ge=0, le=100)
|
||||
risk: str
|
||||
reasons: list[str]
|
||||
scored_at: datetime
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Heuristic risk scoring.
|
||||
|
||||
This is intentionally simple — a weighted feature scorer. Each feature returns
|
||||
a 0-100 sub-score; the overall score is a weighted sum. We keep memory of seen
|
||||
fingerprints per guest so subsequent accesses can be compared against the
|
||||
baseline established by the first one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
def risk_band(score: int) -> str:
|
||||
if score <= 30:
|
||||
return LOW
|
||||
if score <= 60:
|
||||
return MEDIUM
|
||||
if score <= 85:
|
||||
return HIGH
|
||||
return BLOCK
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuestBaseline:
|
||||
fingerprint_digest: str | None = None
|
||||
ip_prefix: str | None = None
|
||||
accesses: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoringResult:
|
||||
score: int
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeuristicScorer:
|
||||
weights: dict[str, float] = field(
|
||||
default_factory=lambda: {
|
||||
"fingerprint_mismatch": 0.40,
|
||||
"ip_change": 0.25,
|
||||
"missing_signals": 0.10,
|
||||
"repeated_access": 0.10,
|
||||
"no_user_agent": 0.15,
|
||||
}
|
||||
)
|
||||
baselines: dict[UUID, GuestBaseline] = field(default_factory=dict)
|
||||
|
||||
def score(self, evt: AccessAttempted) -> ScoringResult:
|
||||
reasons: list[str] = []
|
||||
sub: dict[str, int] = {}
|
||||
|
||||
baseline = self.baselines.get(evt.guest_id, GuestBaseline())
|
||||
current_digest = _fingerprint_digest(evt.fingerprint)
|
||||
current_prefix = _ip_prefix(evt.ip_address)
|
||||
|
||||
if baseline.fingerprint_digest is None:
|
||||
sub["fingerprint_mismatch"] = 0
|
||||
elif baseline.fingerprint_digest == current_digest:
|
||||
sub["fingerprint_mismatch"] = 0
|
||||
else:
|
||||
sub["fingerprint_mismatch"] = 100
|
||||
reasons.append("fingerprint differs from baseline")
|
||||
|
||||
if baseline.ip_prefix is None:
|
||||
sub["ip_change"] = 0
|
||||
elif baseline.ip_prefix == current_prefix:
|
||||
sub["ip_change"] = 0
|
||||
else:
|
||||
sub["ip_change"] = 80
|
||||
reasons.append("ip address changed since first access")
|
||||
|
||||
if not evt.fingerprint:
|
||||
sub["missing_signals"] = 70
|
||||
reasons.append("no device fingerprint provided")
|
||||
else:
|
||||
sub["missing_signals"] = 0
|
||||
|
||||
sub["repeated_access"] = min(baseline.accesses * 10, 60)
|
||||
if baseline.accesses >= 5:
|
||||
reasons.append(f"token accessed {baseline.accesses + 1} times")
|
||||
|
||||
if not evt.user_agent:
|
||||
sub["no_user_agent"] = 80
|
||||
reasons.append("missing user agent")
|
||||
else:
|
||||
sub["no_user_agent"] = 0
|
||||
|
||||
weighted = sum(sub[k] * self.weights[k] for k in self.weights)
|
||||
final = int(round(min(max(weighted, 0), 100)))
|
||||
|
||||
# Update baseline AFTER scoring so the first access sets it without
|
||||
# being penalised against itself.
|
||||
if baseline.fingerprint_digest is None:
|
||||
baseline.fingerprint_digest = current_digest
|
||||
if baseline.ip_prefix is None:
|
||||
baseline.ip_prefix = current_prefix
|
||||
baseline.accesses += 1
|
||||
self.baselines[evt.guest_id] = baseline
|
||||
|
||||
return ScoringResult(score=final, reasons=reasons)
|
||||
|
||||
|
||||
def _fingerprint_digest(fp: dict[str, Any] | None) -> str | None:
|
||||
if not fp:
|
||||
return None
|
||||
items = sorted((str(k), str(v)) for k, v in fp.items())
|
||||
h = hashlib.sha256()
|
||||
for k, v in items:
|
||||
h.update(k.encode())
|
||||
h.update(b"=")
|
||||
h.update(v.encode())
|
||||
h.update(b";")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _ip_prefix(ip: str | None) -> str | None:
|
||||
if not ip:
|
||||
return None
|
||||
if ":" in ip:
|
||||
# IPv6 — keep first 4 hextets
|
||||
parts = ip.split(":")[:4]
|
||||
return ":".join(parts)
|
||||
parts = ip.split(".")
|
||||
if len(parts) == 4:
|
||||
return ".".join(parts[:3])
|
||||
return ip
|
||||
Reference in New Issue
Block a user