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,12 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv
|
||||
.env
|
||||
.env.local
|
||||
tests
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM python:3.12-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --upgrade pip && pip install .
|
||||
|
||||
COPY app ./app
|
||||
COPY fraud ./fraud
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=build /usr/local/bin /usr/local/bin
|
||||
COPY --from=build /app/app /app/app
|
||||
COPY --from=build /app/fraud /app/fraud
|
||||
|
||||
USER 1000:1000
|
||||
EXPOSE 8081 9091
|
||||
|
||||
CMD ["python", "-m", "app.main"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: fraud/v1/fraud.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
'fraud/v1/fraud.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14\x66raud/v1/fraud.proto\x12\x13guestguard.fraud.v1\"\x92\x02\n\x0cScoreRequest\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x10\n\x08guest_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12\x15\n\raccess_log_id\x18\x04 \x01(\t\x12G\n\x0b\x66ingerprint\x18\x05 \x03(\x0b\x32\x32.guestguard.fraud.v1.ScoreRequest.FingerprintEntry\x12\x12\n\nip_address\x18\x06 \x01(\t\x12\x12\n\nuser_agent\x18\x07 \x01(\t\x12\x10\n\x08referrer\x18\x08 \x01(\t\x1a\x32\n\x10\x46ingerprintEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"X\n\rScoreResponse\x12\r\n\x05score\x18\x01 \x01(\x05\x12\'\n\x04risk\x18\x02 \x01(\x0e\x32\x19.guestguard.fraud.v1.Risk\x12\x0f\n\x07reasons\x18\x03 \x03(\t*Z\n\x04Risk\x12\x14\n\x10RISK_UNSPECIFIED\x10\x00\x12\x0c\n\x08RISK_LOW\x10\x01\x12\x0f\n\x0bRISK_MEDIUM\x10\x02\x12\r\n\tRISK_HIGH\x10\x03\x12\x0e\n\nRISK_BLOCK\x10\x04\x32^\n\x0c\x46raudService\x12N\n\x05Score\x12!.guestguard.fraud.v1.ScoreRequest\x1a\".guestguard.fraud.v1.ScoreResponseB=Z;github.com/alchemistkay/guestguard/internal/fraudpb;fraudpbb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'fraud.v1.fraud_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['DESCRIPTOR']._loaded_options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'Z;github.com/alchemistkay/guestguard/internal/fraudpb;fraudpb'
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._loaded_options = None
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_options = b'8\001'
|
||||
_globals['_RISK']._serialized_start=412
|
||||
_globals['_RISK']._serialized_end=502
|
||||
_globals['_SCOREREQUEST']._serialized_start=46
|
||||
_globals['_SCOREREQUEST']._serialized_end=320
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_start=270
|
||||
_globals['_SCOREREQUEST_FINGERPRINTENTRY']._serialized_end=320
|
||||
_globals['_SCORERESPONSE']._serialized_start=322
|
||||
_globals['_SCORERESPONSE']._serialized_end=410
|
||||
_globals['_FRAUDSERVICE']._serialized_start=504
|
||||
_globals['_FRAUDSERVICE']._serialized_end=598
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,58 @@
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Risk(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
RISK_UNSPECIFIED: _ClassVar[Risk]
|
||||
RISK_LOW: _ClassVar[Risk]
|
||||
RISK_MEDIUM: _ClassVar[Risk]
|
||||
RISK_HIGH: _ClassVar[Risk]
|
||||
RISK_BLOCK: _ClassVar[Risk]
|
||||
RISK_UNSPECIFIED: Risk
|
||||
RISK_LOW: Risk
|
||||
RISK_MEDIUM: Risk
|
||||
RISK_HIGH: Risk
|
||||
RISK_BLOCK: Risk
|
||||
|
||||
class ScoreRequest(_message.Message):
|
||||
__slots__ = ("event_id", "guest_id", "token_id", "access_log_id", "fingerprint", "ip_address", "user_agent", "referrer")
|
||||
class FingerprintEntry(_message.Message):
|
||||
__slots__ = ("key", "value")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: str
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
|
||||
EVENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
GUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
TOKEN_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
ACCESS_LOG_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
FINGERPRINT_FIELD_NUMBER: _ClassVar[int]
|
||||
IP_ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
USER_AGENT_FIELD_NUMBER: _ClassVar[int]
|
||||
REFERRER_FIELD_NUMBER: _ClassVar[int]
|
||||
event_id: str
|
||||
guest_id: str
|
||||
token_id: str
|
||||
access_log_id: str
|
||||
fingerprint: _containers.ScalarMap[str, str]
|
||||
ip_address: str
|
||||
user_agent: str
|
||||
referrer: str
|
||||
def __init__(self, event_id: _Optional[str] = ..., guest_id: _Optional[str] = ..., token_id: _Optional[str] = ..., access_log_id: _Optional[str] = ..., fingerprint: _Optional[_Mapping[str, str]] = ..., ip_address: _Optional[str] = ..., user_agent: _Optional[str] = ..., referrer: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class ScoreResponse(_message.Message):
|
||||
__slots__ = ("score", "risk", "reasons")
|
||||
SCORE_FIELD_NUMBER: _ClassVar[int]
|
||||
RISK_FIELD_NUMBER: _ClassVar[int]
|
||||
REASONS_FIELD_NUMBER: _ClassVar[int]
|
||||
score: int
|
||||
risk: Risk
|
||||
reasons: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, score: _Optional[int] = ..., risk: _Optional[_Union[Risk, str]] = ..., reasons: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
import warnings
|
||||
|
||||
from fraud.v1 import fraud_pb2 as fraud_dot_v1_dot_fraud__pb2
|
||||
|
||||
GRPC_GENERATED_VERSION = '1.80.0'
|
||||
GRPC_VERSION = grpc.__version__
|
||||
_version_not_supported = False
|
||||
|
||||
try:
|
||||
from grpc._utilities import first_version_is_lower
|
||||
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
||||
except ImportError:
|
||||
_version_not_supported = True
|
||||
|
||||
if _version_not_supported:
|
||||
raise RuntimeError(
|
||||
f'The grpc package installed is at version {GRPC_VERSION},'
|
||||
+ ' but the generated code in fraud/v1/fraud_pb2_grpc.py depends on'
|
||||
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
||||
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
||||
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
||||
)
|
||||
|
||||
|
||||
class FraudServiceStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Score = channel.unary_unary(
|
||||
'/guestguard.fraud.v1.FraudService/Score',
|
||||
request_serializer=fraud_dot_v1_dot_fraud__pb2.ScoreRequest.SerializeToString,
|
||||
response_deserializer=fraud_dot_v1_dot_fraud__pb2.ScoreResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class FraudServiceServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def Score(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_FraudServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Score': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Score,
|
||||
request_deserializer=fraud_dot_v1_dot_fraud__pb2.ScoreRequest.FromString,
|
||||
response_serializer=fraud_dot_v1_dot_fraud__pb2.ScoreResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'guestguard.fraud.v1.FraudService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
server.add_registered_method_handlers('guestguard.fraud.v1.FraudService', rpc_method_handlers)
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class FraudService(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def Score(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/guestguard.fraud.v1.FraudService/Score',
|
||||
fraud_dot_v1_dot_fraud__pb2.ScoreRequest.SerializeToString,
|
||||
fraud_dot_v1_dot_fraud__pb2.ScoreResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "guestguard-fraud-engine"
|
||||
version = "0.1.0"
|
||||
description = "GuestGuard fraud detection engine"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.5",
|
||||
"nats-py>=2.9",
|
||||
"structlog>=24.4",
|
||||
"grpcio>=1.66",
|
||||
"protobuf>=5.28",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.7",
|
||||
"grpcio-tools>=1.66",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*", "fraud*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "SIM"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Integration test for the gRPC FraudService over an in-process channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
|
||||
from app.grpc_server import FraudServicer, serve_grpc, stop_grpc
|
||||
from app.scoring import HeuristicScorer
|
||||
from fraud.v1 import fraud_pb2, fraud_pb2_grpc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_low_risk_first_access():
|
||||
scorer = HeuristicScorer()
|
||||
server = await serve_grpc(scorer, "127.0.0.1:0")
|
||||
# add_insecure_port returns 0 so we need to fish out the actual bound port via _server's state.
|
||||
# Easier: rebind on a known free port for the test.
|
||||
await stop_grpc(server)
|
||||
|
||||
addr = "127.0.0.1:50951"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
resp = await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=str(uuid4()),
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "Chrome", "platform": "macOS"},
|
||||
ip_address="203.0.113.7",
|
||||
user_agent="Mozilla/5.0",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert resp.score <= 30
|
||||
assert resp.risk == fraud_pb2.RISK_LOW
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_high_risk_after_baseline_change():
|
||||
scorer = HeuristicScorer()
|
||||
addr = "127.0.0.1:50952"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
guest_id = str(uuid4())
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
|
||||
await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=guest_id,
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "Chrome"},
|
||||
ip_address="203.0.113.7",
|
||||
user_agent="Mozilla/5.0",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
resp = await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id=str(uuid4()),
|
||||
guest_id=guest_id,
|
||||
token_id=str(uuid4()),
|
||||
access_log_id=str(uuid4()),
|
||||
fingerprint={"ua": "curl/8"},
|
||||
ip_address="198.51.100.42",
|
||||
user_agent="",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert resp.score >= 60
|
||||
assert resp.risk in {fraud_pb2.RISK_HIGH, fraud_pb2.RISK_BLOCK}
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_uuid_returns_invalid_argument():
|
||||
scorer = HeuristicScorer()
|
||||
addr = "127.0.0.1:50953"
|
||||
server = await serve_grpc(scorer, addr)
|
||||
try:
|
||||
async with grpc.aio.insecure_channel(addr) as channel:
|
||||
stub = fraud_pb2_grpc.FraudServiceStub(channel)
|
||||
with pytest.raises(grpc.RpcError) as excinfo:
|
||||
await stub.Score(
|
||||
fraud_pb2.ScoreRequest(
|
||||
event_id="not-a-uuid",
|
||||
guest_id=str(uuid4()),
|
||||
token_id=str(uuid4()),
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
assert excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT
|
||||
finally:
|
||||
await stop_grpc(server)
|
||||
|
||||
|
||||
def test_servicer_constructs():
|
||||
# Ensures the servicer wires up against the generated stub.
|
||||
FraudServicer(HeuristicScorer())
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.schemas import AccessAttempted
|
||||
from app.scoring import HeuristicScorer, risk_band
|
||||
|
||||
|
||||
def _evt(
|
||||
*,
|
||||
guest_id=None,
|
||||
fingerprint=None,
|
||||
ip=None,
|
||||
user_agent="Mozilla/5.0",
|
||||
):
|
||||
return AccessAttempted(
|
||||
event_id=uuid4(),
|
||||
guest_id=guest_id or uuid4(),
|
||||
token_id=uuid4(),
|
||||
access_log_id=uuid4(),
|
||||
fingerprint=fingerprint,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_first_access_with_full_signals_is_low_risk():
|
||||
scorer = HeuristicScorer()
|
||||
evt = _evt(
|
||||
fingerprint={"ua": "Chrome", "platform": "macOS"},
|
||||
ip="203.0.113.7",
|
||||
)
|
||||
res = scorer.score(evt)
|
||||
assert res.score <= 30
|
||||
assert risk_band(res.score) == "low"
|
||||
|
||||
|
||||
def test_fingerprint_change_drives_score_up():
|
||||
scorer = HeuristicScorer()
|
||||
guest = uuid4()
|
||||
first = _evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7")
|
||||
scorer.score(first)
|
||||
|
||||
second = _evt(guest_id=guest, fingerprint={"ua": "Safari"}, ip="203.0.113.7")
|
||||
res = scorer.score(second)
|
||||
|
||||
assert res.score >= 40
|
||||
assert any("fingerprint" in r for r in res.reasons)
|
||||
|
||||
|
||||
def test_ip_change_and_fingerprint_change_classify_high_or_block():
|
||||
scorer = HeuristicScorer()
|
||||
guest = uuid4()
|
||||
scorer.score(_evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7"))
|
||||
|
||||
suspicious = _evt(
|
||||
guest_id=guest,
|
||||
fingerprint={"ua": "Curl/8"},
|
||||
ip="198.51.100.42",
|
||||
user_agent=None,
|
||||
)
|
||||
res = scorer.score(suspicious)
|
||||
|
||||
assert res.score >= 60
|
||||
assert risk_band(res.score) in {"high", "block"}
|
||||
|
||||
|
||||
def test_missing_fingerprint_and_user_agent_flagged():
|
||||
scorer = HeuristicScorer()
|
||||
res = scorer.score(_evt(fingerprint=None, ip="203.0.113.1", user_agent=None))
|
||||
assert "no device fingerprint provided" in res.reasons
|
||||
assert "missing user agent" in res.reasons
|
||||
|
||||
|
||||
def test_score_clamped_to_0_100():
|
||||
scorer = HeuristicScorer()
|
||||
# 10 successive accesses with no fingerprint, no UA, changing IPs
|
||||
guest = uuid4()
|
||||
for i in range(12):
|
||||
res = scorer.score(_evt(guest_id=guest, fingerprint=None, ip=f"10.0.{i}.1", user_agent=None))
|
||||
assert 0 <= res.score <= 100
|
||||
Reference in New Issue
Block a user