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:
Kwaku Danso
2026-05-11 21:08:56 +01:00
parent f760fc3e21
commit 3f8bc58ca9
89 changed files with 22729 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__
*.pyc
*.pyo
.venv
.env
.env.local
tests
.pytest_cache
.ruff_cache
*.egg-info
build
dist
+29
View File
@@ -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"]
View File
+28
View File
@@ -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()
+74
View File
@@ -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)
+69
View File
@@ -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)
+80
View File
@@ -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()
+89
View File
@@ -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
+30
View File
@@ -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
+138
View File
@@ -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
View File
View File
+47
View File
@@ -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)
+58
View File
@@ -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: ...
+97
View File
@@ -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)
+43
View File
@@ -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"]
View File
+111
View File
@@ -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())
+81
View File
@@ -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