Skip to content

Commit 292af72

Browse files
captivitclaude
andcommitted
feat: add API key auth, health checks, WebSocket, Zustand stores, integration tests, fix notification enum
- Add API key authentication middleware with dev-mode bypass - Implement real readiness checks (DB + Redis connectivity) - Add WebSocket real-time event broadcasting via /ws endpoint - Create Zustand stores for alerts, incidents, stats, silences, notifications, and WS - Refactor frontend hooks to thin wrappers around Zustand stores - Rewrite App.tsx to use store-driven state management - Add 34 integration tests covering all API endpoints - Fix notification channel enum bug (PostgreSQL expected lowercase values) - Add WebSocket event emission to alert/incident service operations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f93919 commit 292af72

26 files changed

Lines changed: 1759 additions & 482 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ REDIS_URL=redis://localhost:6379/0
1717
# Application
1818
# ──────────────────────────────────────
1919
SECRET_KEY=change-me-to-a-random-secret-key
20+
API_KEY=
2021
APP_ENV=development
2122
LOG_LEVEL=INFO
2223
API_PREFIX=/api/v1

backend/api/deps.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Shared API dependencies (auth, etc.)."""
2+
3+
import secrets
4+
5+
from fastapi import HTTPException, Security
6+
from fastapi.security import APIKeyHeader
7+
8+
from backend.config import get_settings
9+
10+
settings = get_settings()
11+
12+
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
13+
14+
15+
async def require_api_key(
16+
api_key: str | None = Security(_api_key_header),
17+
) -> str:
18+
"""Validate the API key from the X-API-Key header.
19+
20+
In development mode with the default secret key, auth is skipped
21+
to avoid friction during local development.
22+
"""
23+
# Skip auth in dev mode when using the default placeholder key
24+
if settings.is_dev and settings.api_key == "":
25+
return "dev"
26+
27+
if not api_key:
28+
raise HTTPException(status_code=401, detail="Missing API key")
29+
30+
if not secrets.compare_digest(api_key, settings.api_key):
31+
raise HTTPException(status_code=403, detail="Invalid API key")
32+
33+
return api_key

backend/api/routes/health.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
import logging
2+
13
from fastapi import APIRouter
4+
from sqlalchemy import text
5+
6+
from backend.config import get_settings
7+
from backend.database import async_session
8+
9+
logger = logging.getLogger(__name__)
210

311
router = APIRouter(tags=["health"])
412

13+
settings = get_settings()
14+
515

616
@router.get("/health")
717
async def health_check() -> dict:
@@ -12,5 +22,37 @@ async def health_check() -> dict:
1222
@router.get("/health/ready")
1323
async def readiness_check() -> dict:
1424
"""Readiness check — verifies dependencies are available."""
15-
# TODO: Check DB and Redis connectivity
16-
return {"status": "ready"}
25+
checks: dict[str, str] = {}
26+
27+
# Check database
28+
try:
29+
async with async_session() as session:
30+
await session.execute(text("SELECT 1"))
31+
checks["database"] = "ok"
32+
except Exception as e:
33+
logger.warning(f"Database readiness check failed: {e}")
34+
checks["database"] = "unavailable"
35+
36+
# Check Redis
37+
try:
38+
import redis.asyncio as aioredis
39+
40+
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=2)
41+
await r.ping()
42+
await r.aclose()
43+
checks["redis"] = "ok"
44+
except Exception as e:
45+
logger.warning(f"Redis readiness check failed: {e}")
46+
checks["redis"] = "unavailable"
47+
48+
all_ok = all(v == "ok" for v in checks.values())
49+
50+
if not all_ok:
51+
from fastapi.responses import JSONResponse
52+
53+
return JSONResponse(
54+
status_code=503,
55+
content={"status": "degraded", "checks": checks},
56+
)
57+
58+
return {"status": "ready", "checks": checks}

backend/api/routes/ws.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""WebSocket endpoint for real-time alert/incident updates."""
2+
3+
import json
4+
import logging
5+
import secrets
6+
7+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8+
9+
from backend.config import get_settings
10+
11+
logger = logging.getLogger(__name__)
12+
13+
router = APIRouter(tags=["websocket"])
14+
15+
settings = get_settings()
16+
17+
18+
class ConnectionManager:
19+
"""Manages active WebSocket connections and broadcasts events."""
20+
21+
def __init__(self) -> None:
22+
self._connections: set[WebSocket] = set()
23+
24+
async def connect(self, ws: WebSocket) -> None:
25+
await ws.accept()
26+
self._connections.add(ws)
27+
logger.info(f"WebSocket connected ({len(self._connections)} active)")
28+
29+
def disconnect(self, ws: WebSocket) -> None:
30+
self._connections.discard(ws)
31+
logger.info(f"WebSocket disconnected ({len(self._connections)} active)")
32+
33+
async def broadcast(self, event: dict) -> None:
34+
"""Send an event to all connected clients."""
35+
if not self._connections:
36+
return
37+
payload = json.dumps(event)
38+
dead: list[WebSocket] = []
39+
for ws in self._connections:
40+
try:
41+
await ws.send_text(payload)
42+
except Exception:
43+
dead.append(ws)
44+
for ws in dead:
45+
self._connections.discard(ws)
46+
47+
48+
manager = ConnectionManager()
49+
50+
51+
def _check_ws_auth(api_key: str | None) -> bool:
52+
"""Validate API key for WebSocket connections."""
53+
if settings.is_dev and settings.api_key == "":
54+
return True
55+
if not api_key:
56+
return False
57+
return secrets.compare_digest(api_key, settings.api_key)
58+
59+
60+
@router.websocket("/ws")
61+
async def websocket_endpoint(ws: WebSocket) -> None:
62+
"""Real-time event stream.
63+
64+
Clients connect and receive JSON events when alerts or incidents
65+
are created, updated, or resolved.
66+
67+
Auth: pass API key as ?token= query param.
68+
69+
Event format:
70+
{"type": "alert.created", "data": {...}}
71+
{"type": "incident.updated", "data": {...}}
72+
"""
73+
token = ws.query_params.get("token")
74+
if not _check_ws_auth(token):
75+
await ws.close(code=4003, reason="Invalid or missing API key")
76+
return
77+
78+
await manager.connect(ws)
79+
try:
80+
while True:
81+
# Keep connection alive; clients can send pings
82+
data = await ws.receive_text()
83+
if data == "ping":
84+
await ws.send_text(json.dumps({"type": "pong"}))
85+
except WebSocketDisconnect:
86+
pass
87+
finally:
88+
manager.disconnect(ws)
89+
90+
91+
async def emit_event(event_type: str, data: dict) -> None:
92+
"""Broadcast an event to all connected WebSocket clients.
93+
94+
Call this from services when state changes occur.
95+
"""
96+
await manager.broadcast({"type": event_type, "data": data})

backend/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
1818
log_level: str = "INFO"
1919
api_prefix: str = "/api/v1"
2020
secret_key: str = "change-me-to-a-random-secret-key"
21+
api_key: str = ""
2122

2223
# ── Database ─────────────────────────────────────────
2324
database_url: str = "postgresql+asyncpg://solace:solace@localhost:5432/solace"

backend/main.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22

3-
from fastapi import FastAPI
3+
from fastapi import Depends, FastAPI
44
from fastapi.middleware.cors import CORSMiddleware
55

6+
from backend.api.deps import require_api_key
67
from backend.api.routes import alerts, health, incidents, notifications, silences, stats, webhooks
8+
from backend.api.routes.ws import router as ws_router
79
from backend.config import get_settings
810

911
settings = get_settings()
@@ -40,16 +42,20 @@
4042

4143
# ─── Routes ──────────────────────────────────────────────
4244

43-
# Health checks at root level (no prefix)
45+
# Health checks at root level (no prefix, no auth — used by k8s probes)
4446
app.include_router(health.router)
4547

46-
# API routes under /api/v1
47-
app.include_router(webhooks.router, prefix=settings.api_prefix)
48-
app.include_router(alerts.router, prefix=settings.api_prefix)
49-
app.include_router(incidents.router, prefix=settings.api_prefix)
50-
app.include_router(stats.router, prefix=settings.api_prefix)
51-
app.include_router(silences.router, prefix=settings.api_prefix)
52-
app.include_router(notifications.router, prefix=settings.api_prefix)
48+
# WebSocket — auth is checked inside the handler (WS can't use header deps)
49+
app.include_router(ws_router, prefix=settings.api_prefix)
50+
51+
# API routes under /api/v1 — all require API key
52+
api_deps = [Depends(require_api_key)]
53+
app.include_router(webhooks.router, prefix=settings.api_prefix, dependencies=api_deps)
54+
app.include_router(alerts.router, prefix=settings.api_prefix, dependencies=api_deps)
55+
app.include_router(incidents.router, prefix=settings.api_prefix, dependencies=api_deps)
56+
app.include_router(stats.router, prefix=settings.api_prefix, dependencies=api_deps)
57+
app.include_router(silences.router, prefix=settings.api_prefix, dependencies=api_deps)
58+
app.include_router(notifications.router, prefix=settings.api_prefix, dependencies=api_deps)
5359

5460

5561
# ─── Startup / Shutdown ──────────────────────────────────

backend/models/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,8 @@ class NotificationChannel(Base):
306306
)
307307
name: Mapped[str] = mapped_column(String(255), nullable=False)
308308
channel_type: Mapped[ChannelType] = mapped_column(
309-
Enum(ChannelType), nullable=False
309+
Enum(ChannelType, values_callable=lambda x: [e.value for e in x]),
310+
nullable=False,
310311
)
311312
config: Mapped[dict] = mapped_column(JSONB, default=dict)
312313
is_active: Mapped[bool] = mapped_column(default=True)
@@ -339,7 +340,9 @@ class NotificationLog(Base):
339340
)
340341
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
341342
status: Mapped[NotificationStatus] = mapped_column(
342-
Enum(NotificationStatus), nullable=False, default=NotificationStatus.PENDING
343+
Enum(NotificationStatus, values_callable=lambda x: [e.value for e in x]),
344+
nullable=False,
345+
default=NotificationStatus.PENDING,
343346
)
344347
error_message: Mapped[str | None] = mapped_column(Text)
345348
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

backend/services/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ async def ingest_alert(
5757
f"Duplicate alert: {normalized.name} (fingerprint={fingerprint}, "
5858
f"count={updated.duplicate_count})"
5959
)
60+
61+
from backend.api.routes.ws import emit_event
62+
await emit_event("alert.updated", {
63+
"alert_id": str(updated.id),
64+
"duplicate_count": updated.duplicate_count,
65+
})
66+
6067
return updated, True
6168

6269
# Step 3b: Check silence windows
@@ -137,6 +144,17 @@ async def ingest_alert(
137144
f"{f', incident={str(incident.id)[:8]}' if incident else ''})"
138145
)
139146

147+
# Emit real-time events
148+
from backend.api.routes.ws import emit_event
149+
150+
await emit_event("alert.created", {"alert_id": str(alert.id), "name": alert.name})
151+
if incident and result.event_type == "incident_created":
152+
inc_data = {"incident_id": str(incident.id), "title": incident.title}
153+
await emit_event("incident.created", inc_data)
154+
elif incident and result.event_type == "severity_changed":
155+
inc_data = {"incident_id": str(incident.id), "title": incident.title}
156+
await emit_event("incident.updated", inc_data)
157+
140158
return alert, False
141159

142160

@@ -229,6 +247,10 @@ async def acknowledge_alert(
229247
alert.updated_at = now
230248
await db.flush()
231249
await db.refresh(alert)
250+
251+
from backend.api.routes.ws import emit_event
252+
await emit_event("alert.updated", {"alert_id": str(alert.id), "status": "acknowledged"})
253+
232254
return alert
233255

234256

@@ -251,6 +273,10 @@ async def resolve_alert(
251273
alert.updated_at = now
252274
await db.flush()
253275
await db.refresh(alert)
276+
277+
from backend.api.routes.ws import emit_event
278+
await emit_event("alert.updated", {"alert_id": str(alert.id), "status": "resolved"})
279+
254280
return alert
255281

256282

@@ -363,6 +389,11 @@ async def acknowledge_incident(
363389

364390
await db.flush()
365391
await db.refresh(incident)
392+
393+
from backend.api.routes.ws import emit_event
394+
inc_id = str(incident.id)
395+
await emit_event("incident.updated", {"incident_id": inc_id, "status": "acknowledged"})
396+
366397
return incident
367398

368399

@@ -402,6 +433,10 @@ async def resolve_incident(
402433

403434
await db.flush()
404435
await db.refresh(incident)
436+
437+
from backend.api.routes.ws import emit_event
438+
await emit_event("incident.updated", {"incident_id": str(incident.id), "status": "resolved"})
439+
405440
return incident
406441

407442

0 commit comments

Comments
 (0)