truthound-dashboard 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""API middleware for security, logging, and rate limiting.
|
|
2
|
+
|
|
3
|
+
This module provides extensible middleware components for:
|
|
4
|
+
- Rate limiting with configurable strategies
|
|
5
|
+
- Security headers
|
|
6
|
+
- Request/response logging
|
|
7
|
+
- Authentication (optional)
|
|
8
|
+
|
|
9
|
+
The middleware uses the Chain of Responsibility pattern for
|
|
10
|
+
flexible composition.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from truthound_dashboard.api.middleware import (
|
|
14
|
+
RateLimitMiddleware,
|
|
15
|
+
SecurityHeadersMiddleware,
|
|
16
|
+
RequestLoggingMiddleware,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
20
|
+
app.add_middleware(RateLimitMiddleware, requests_per_minute=120)
|
|
21
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import hashlib
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
from fastapi import Request, Response
|
|
37
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
38
|
+
from starlette.responses import JSONResponse
|
|
39
|
+
|
|
40
|
+
from truthound_dashboard.core.exceptions import ErrorCode
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Rate Limiting
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class RateLimitConfig:
|
|
52
|
+
"""Configuration for rate limiting.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
requests_per_minute: Maximum requests per minute.
|
|
56
|
+
burst_size: Maximum burst size for token bucket.
|
|
57
|
+
by_ip: Rate limit by IP address.
|
|
58
|
+
by_path: Rate limit by path (in addition to IP).
|
|
59
|
+
exclude_paths: Paths to exclude from rate limiting.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
requests_per_minute: int = 60
|
|
63
|
+
burst_size: int = 10
|
|
64
|
+
by_ip: bool = True
|
|
65
|
+
by_path: bool = False
|
|
66
|
+
exclude_paths: list[str] = field(default_factory=lambda: ["/health", "/docs"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RateLimitStrategy(ABC):
|
|
70
|
+
"""Abstract base class for rate limiting strategies."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def is_allowed(self, key: str) -> tuple[bool, dict[str, Any]]:
|
|
74
|
+
"""Check if request is allowed.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
key: Rate limit key (e.g., IP address).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Tuple of (allowed, info dict with remaining, reset_at, etc.)
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def cleanup(self) -> None:
|
|
86
|
+
"""Cleanup expired entries."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SlidingWindowRateLimiter(RateLimitStrategy):
|
|
91
|
+
"""Sliding window rate limiter.
|
|
92
|
+
|
|
93
|
+
Tracks requests in a sliding time window for smooth rate limiting.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
requests_per_minute: int = 60,
|
|
99
|
+
window_seconds: int = 60,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Initialize rate limiter.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
requests_per_minute: Maximum requests per window.
|
|
105
|
+
window_seconds: Window size in seconds.
|
|
106
|
+
"""
|
|
107
|
+
self._requests_per_minute = requests_per_minute
|
|
108
|
+
self._window_seconds = window_seconds
|
|
109
|
+
self._requests: dict[str, list[float]] = defaultdict(list)
|
|
110
|
+
self._lock = asyncio.Lock()
|
|
111
|
+
|
|
112
|
+
def is_allowed(self, key: str) -> tuple[bool, dict[str, Any]]:
|
|
113
|
+
"""Check if request is allowed using sliding window."""
|
|
114
|
+
now = time.time()
|
|
115
|
+
window_start = now - self._window_seconds
|
|
116
|
+
|
|
117
|
+
# Clean old requests
|
|
118
|
+
self._requests[key] = [
|
|
119
|
+
t for t in self._requests[key] if t > window_start
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
current_count = len(self._requests[key])
|
|
123
|
+
remaining = max(0, self._requests_per_minute - current_count)
|
|
124
|
+
reset_at = int(now + self._window_seconds)
|
|
125
|
+
|
|
126
|
+
info = {
|
|
127
|
+
"limit": self._requests_per_minute,
|
|
128
|
+
"remaining": remaining,
|
|
129
|
+
"reset_at": reset_at,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if current_count >= self._requests_per_minute:
|
|
133
|
+
return False, info
|
|
134
|
+
|
|
135
|
+
# Record request
|
|
136
|
+
self._requests[key].append(now)
|
|
137
|
+
info["remaining"] = remaining - 1
|
|
138
|
+
|
|
139
|
+
return True, info
|
|
140
|
+
|
|
141
|
+
def cleanup(self) -> None:
|
|
142
|
+
"""Remove entries with no recent requests."""
|
|
143
|
+
now = time.time()
|
|
144
|
+
window_start = now - self._window_seconds
|
|
145
|
+
|
|
146
|
+
keys_to_remove = []
|
|
147
|
+
for key, timestamps in self._requests.items():
|
|
148
|
+
if all(t <= window_start for t in timestamps):
|
|
149
|
+
keys_to_remove.append(key)
|
|
150
|
+
|
|
151
|
+
for key in keys_to_remove:
|
|
152
|
+
del self._requests[key]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TokenBucketRateLimiter(RateLimitStrategy):
|
|
156
|
+
"""Token bucket rate limiter.
|
|
157
|
+
|
|
158
|
+
Allows burst traffic while maintaining average rate limit.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
requests_per_minute: int = 60,
|
|
164
|
+
bucket_size: int = 10,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Initialize token bucket rate limiter.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
requests_per_minute: Token refill rate.
|
|
170
|
+
bucket_size: Maximum tokens in bucket.
|
|
171
|
+
"""
|
|
172
|
+
self._rate = requests_per_minute / 60.0 # tokens per second
|
|
173
|
+
self._bucket_size = bucket_size
|
|
174
|
+
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_update)
|
|
175
|
+
|
|
176
|
+
def is_allowed(self, key: str) -> tuple[bool, dict[str, Any]]:
|
|
177
|
+
"""Check if request is allowed using token bucket."""
|
|
178
|
+
now = time.time()
|
|
179
|
+
|
|
180
|
+
if key not in self._buckets:
|
|
181
|
+
self._buckets[key] = (self._bucket_size - 1, now)
|
|
182
|
+
return True, {
|
|
183
|
+
"limit": self._bucket_size,
|
|
184
|
+
"remaining": self._bucket_size - 1,
|
|
185
|
+
"reset_at": int(now + (1 / self._rate)),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
tokens, last_update = self._buckets[key]
|
|
189
|
+
elapsed = now - last_update
|
|
190
|
+
|
|
191
|
+
# Add tokens based on elapsed time
|
|
192
|
+
tokens = min(self._bucket_size, tokens + elapsed * self._rate)
|
|
193
|
+
|
|
194
|
+
info = {
|
|
195
|
+
"limit": self._bucket_size,
|
|
196
|
+
"remaining": int(tokens),
|
|
197
|
+
"reset_at": int(now + ((self._bucket_size - tokens) / self._rate)),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if tokens < 1:
|
|
201
|
+
return False, info
|
|
202
|
+
|
|
203
|
+
# Consume token
|
|
204
|
+
self._buckets[key] = (tokens - 1, now)
|
|
205
|
+
info["remaining"] = int(tokens - 1)
|
|
206
|
+
|
|
207
|
+
return True, info
|
|
208
|
+
|
|
209
|
+
def cleanup(self) -> None:
|
|
210
|
+
"""Remove buckets that are full (no recent requests)."""
|
|
211
|
+
keys_to_remove = []
|
|
212
|
+
for key, (tokens, _) in self._buckets.items():
|
|
213
|
+
if tokens >= self._bucket_size:
|
|
214
|
+
keys_to_remove.append(key)
|
|
215
|
+
|
|
216
|
+
for key in keys_to_remove:
|
|
217
|
+
del self._buckets[key]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
221
|
+
"""Rate limiting middleware with configurable strategies.
|
|
222
|
+
|
|
223
|
+
Limits request rate by IP address and optionally by path.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
app: Any,
|
|
229
|
+
config: RateLimitConfig | None = None,
|
|
230
|
+
strategy: RateLimitStrategy | None = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Initialize rate limit middleware.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
app: ASGI application.
|
|
236
|
+
config: Rate limit configuration.
|
|
237
|
+
strategy: Rate limiting strategy. Defaults to sliding window.
|
|
238
|
+
"""
|
|
239
|
+
super().__init__(app)
|
|
240
|
+
self._config = config or RateLimitConfig()
|
|
241
|
+
self._strategy = strategy or SlidingWindowRateLimiter(
|
|
242
|
+
requests_per_minute=self._config.requests_per_minute
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def dispatch(
|
|
246
|
+
self,
|
|
247
|
+
request: Request,
|
|
248
|
+
call_next: RequestResponseEndpoint,
|
|
249
|
+
) -> Response:
|
|
250
|
+
"""Process request through rate limiter."""
|
|
251
|
+
# Skip excluded paths
|
|
252
|
+
if request.url.path in self._config.exclude_paths:
|
|
253
|
+
return await call_next(request)
|
|
254
|
+
|
|
255
|
+
# Build rate limit key
|
|
256
|
+
key = self._build_key(request)
|
|
257
|
+
|
|
258
|
+
# Check rate limit
|
|
259
|
+
allowed, info = self._strategy.is_allowed(key)
|
|
260
|
+
|
|
261
|
+
if not allowed:
|
|
262
|
+
logger.warning(
|
|
263
|
+
f"Rate limit exceeded for {key}",
|
|
264
|
+
extra={"path": request.url.path},
|
|
265
|
+
)
|
|
266
|
+
return JSONResponse(
|
|
267
|
+
status_code=429,
|
|
268
|
+
content={
|
|
269
|
+
"success": False,
|
|
270
|
+
"error": {
|
|
271
|
+
"code": ErrorCode.RATE_LIMIT_EXCEEDED.value,
|
|
272
|
+
"message": "Too many requests. Please try again later.",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
headers={
|
|
276
|
+
"X-RateLimit-Limit": str(info["limit"]),
|
|
277
|
+
"X-RateLimit-Remaining": str(info["remaining"]),
|
|
278
|
+
"X-RateLimit-Reset": str(info["reset_at"]),
|
|
279
|
+
"Retry-After": str(info["reset_at"] - int(time.time())),
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Process request
|
|
284
|
+
response = await call_next(request)
|
|
285
|
+
|
|
286
|
+
# Add rate limit headers
|
|
287
|
+
response.headers["X-RateLimit-Limit"] = str(info["limit"])
|
|
288
|
+
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
|
|
289
|
+
response.headers["X-RateLimit-Reset"] = str(info["reset_at"])
|
|
290
|
+
|
|
291
|
+
return response
|
|
292
|
+
|
|
293
|
+
def _build_key(self, request: Request) -> str:
|
|
294
|
+
"""Build rate limit key from request."""
|
|
295
|
+
parts = []
|
|
296
|
+
|
|
297
|
+
if self._config.by_ip:
|
|
298
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
299
|
+
parts.append(client_ip)
|
|
300
|
+
|
|
301
|
+
if self._config.by_path:
|
|
302
|
+
parts.append(request.url.path)
|
|
303
|
+
|
|
304
|
+
return ":".join(parts) if parts else "global"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# =============================================================================
|
|
308
|
+
# Security Headers
|
|
309
|
+
# =============================================================================
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class SecurityHeadersConfig:
|
|
314
|
+
"""Configuration for security headers.
|
|
315
|
+
|
|
316
|
+
Attributes:
|
|
317
|
+
content_type_options: X-Content-Type-Options value.
|
|
318
|
+
frame_options: X-Frame-Options value.
|
|
319
|
+
xss_protection: X-XSS-Protection value.
|
|
320
|
+
referrer_policy: Referrer-Policy value.
|
|
321
|
+
content_security_policy: Content-Security-Policy value.
|
|
322
|
+
strict_transport_security: Strict-Transport-Security value.
|
|
323
|
+
permissions_policy: Permissions-Policy value.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
content_type_options: str = "nosniff"
|
|
327
|
+
frame_options: str = "DENY"
|
|
328
|
+
xss_protection: str = "1; mode=block"
|
|
329
|
+
referrer_policy: str = "strict-origin-when-cross-origin"
|
|
330
|
+
content_security_policy: str | None = None
|
|
331
|
+
strict_transport_security: str | None = None
|
|
332
|
+
permissions_policy: str | None = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
336
|
+
"""Middleware to add security headers to responses."""
|
|
337
|
+
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
app: Any,
|
|
341
|
+
config: SecurityHeadersConfig | None = None,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Initialize security headers middleware.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
app: ASGI application.
|
|
347
|
+
config: Security headers configuration.
|
|
348
|
+
"""
|
|
349
|
+
super().__init__(app)
|
|
350
|
+
self._config = config or SecurityHeadersConfig()
|
|
351
|
+
|
|
352
|
+
async def dispatch(
|
|
353
|
+
self,
|
|
354
|
+
request: Request,
|
|
355
|
+
call_next: RequestResponseEndpoint,
|
|
356
|
+
) -> Response:
|
|
357
|
+
"""Add security headers to response."""
|
|
358
|
+
response = await call_next(request)
|
|
359
|
+
|
|
360
|
+
# Always add these headers
|
|
361
|
+
response.headers["X-Content-Type-Options"] = self._config.content_type_options
|
|
362
|
+
response.headers["X-Frame-Options"] = self._config.frame_options
|
|
363
|
+
response.headers["X-XSS-Protection"] = self._config.xss_protection
|
|
364
|
+
response.headers["Referrer-Policy"] = self._config.referrer_policy
|
|
365
|
+
|
|
366
|
+
# Optionally add these headers
|
|
367
|
+
if self._config.content_security_policy:
|
|
368
|
+
response.headers["Content-Security-Policy"] = (
|
|
369
|
+
self._config.content_security_policy
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if self._config.strict_transport_security:
|
|
373
|
+
response.headers["Strict-Transport-Security"] = (
|
|
374
|
+
self._config.strict_transport_security
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if self._config.permissions_policy:
|
|
378
|
+
response.headers["Permissions-Policy"] = self._config.permissions_policy
|
|
379
|
+
|
|
380
|
+
return response
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# =============================================================================
|
|
384
|
+
# Request Logging
|
|
385
|
+
# =============================================================================
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass
|
|
389
|
+
class RequestLogConfig:
|
|
390
|
+
"""Configuration for request logging.
|
|
391
|
+
|
|
392
|
+
Attributes:
|
|
393
|
+
log_headers: Whether to log request headers.
|
|
394
|
+
log_body: Whether to log request body.
|
|
395
|
+
exclude_paths: Paths to exclude from logging.
|
|
396
|
+
sensitive_headers: Headers to mask in logs.
|
|
397
|
+
max_body_length: Maximum body length to log.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
log_headers: bool = False
|
|
401
|
+
log_body: bool = False
|
|
402
|
+
exclude_paths: list[str] = field(default_factory=lambda: ["/health"])
|
|
403
|
+
sensitive_headers: list[str] = field(
|
|
404
|
+
default_factory=lambda: ["authorization", "cookie", "x-api-key"]
|
|
405
|
+
)
|
|
406
|
+
max_body_length: int = 1000
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
410
|
+
"""Middleware for logging HTTP requests and responses."""
|
|
411
|
+
|
|
412
|
+
def __init__(
|
|
413
|
+
self,
|
|
414
|
+
app: Any,
|
|
415
|
+
config: RequestLogConfig | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Initialize request logging middleware.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
app: ASGI application.
|
|
421
|
+
config: Request logging configuration.
|
|
422
|
+
"""
|
|
423
|
+
super().__init__(app)
|
|
424
|
+
self._config = config or RequestLogConfig()
|
|
425
|
+
|
|
426
|
+
async def dispatch(
|
|
427
|
+
self,
|
|
428
|
+
request: Request,
|
|
429
|
+
call_next: RequestResponseEndpoint,
|
|
430
|
+
) -> Response:
|
|
431
|
+
"""Log request and response."""
|
|
432
|
+
# Skip excluded paths
|
|
433
|
+
if request.url.path in self._config.exclude_paths:
|
|
434
|
+
return await call_next(request)
|
|
435
|
+
|
|
436
|
+
# Generate request ID
|
|
437
|
+
request_id = hashlib.sha256(
|
|
438
|
+
f"{time.time()}{request.client}".encode()
|
|
439
|
+
).hexdigest()[:8]
|
|
440
|
+
|
|
441
|
+
start_time = time.time()
|
|
442
|
+
|
|
443
|
+
# Build log context
|
|
444
|
+
log_context = {
|
|
445
|
+
"request_id": request_id,
|
|
446
|
+
"method": request.method,
|
|
447
|
+
"path": request.url.path,
|
|
448
|
+
"client_ip": request.client.host if request.client else "unknown",
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if self._config.log_headers:
|
|
452
|
+
log_context["headers"] = self._mask_headers(dict(request.headers))
|
|
453
|
+
|
|
454
|
+
logger.info(
|
|
455
|
+
f"[{request_id}] {request.method} {request.url.path} - Started",
|
|
456
|
+
extra=log_context,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Process request
|
|
460
|
+
try:
|
|
461
|
+
response = await call_next(request)
|
|
462
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
463
|
+
|
|
464
|
+
log_context["status_code"] = response.status_code
|
|
465
|
+
log_context["duration_ms"] = duration_ms
|
|
466
|
+
|
|
467
|
+
log_level = logging.INFO
|
|
468
|
+
if response.status_code >= 500:
|
|
469
|
+
log_level = logging.ERROR
|
|
470
|
+
elif response.status_code >= 400:
|
|
471
|
+
log_level = logging.WARNING
|
|
472
|
+
|
|
473
|
+
logger.log(
|
|
474
|
+
log_level,
|
|
475
|
+
f"[{request_id}] {request.method} {request.url.path} - "
|
|
476
|
+
f"{response.status_code} ({duration_ms}ms)",
|
|
477
|
+
extra=log_context,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Add request ID to response headers
|
|
481
|
+
response.headers["X-Request-ID"] = request_id
|
|
482
|
+
|
|
483
|
+
return response
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
487
|
+
log_context["duration_ms"] = duration_ms
|
|
488
|
+
log_context["error"] = str(e)
|
|
489
|
+
|
|
490
|
+
logger.error(
|
|
491
|
+
f"[{request_id}] {request.method} {request.url.path} - "
|
|
492
|
+
f"Error ({duration_ms}ms): {e}",
|
|
493
|
+
extra=log_context,
|
|
494
|
+
exc_info=True,
|
|
495
|
+
)
|
|
496
|
+
raise
|
|
497
|
+
|
|
498
|
+
def _mask_headers(self, headers: dict[str, str]) -> dict[str, str]:
|
|
499
|
+
"""Mask sensitive headers."""
|
|
500
|
+
masked = {}
|
|
501
|
+
for key, value in headers.items():
|
|
502
|
+
if key.lower() in self._config.sensitive_headers:
|
|
503
|
+
masked[key] = "***"
|
|
504
|
+
else:
|
|
505
|
+
masked[key] = value
|
|
506
|
+
return masked
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# =============================================================================
|
|
510
|
+
# Authentication Middleware
|
|
511
|
+
# =============================================================================
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class BasicAuthMiddleware(BaseHTTPMiddleware):
|
|
515
|
+
"""Optional basic authentication middleware.
|
|
516
|
+
|
|
517
|
+
Only active when auth_enabled is True in settings.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
def __init__(
|
|
521
|
+
self,
|
|
522
|
+
app: Any,
|
|
523
|
+
password: str | None = None,
|
|
524
|
+
exclude_paths: list[str] | None = None,
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Initialize basic auth middleware.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
app: ASGI application.
|
|
530
|
+
password: Password for authentication.
|
|
531
|
+
exclude_paths: Paths to exclude from authentication.
|
|
532
|
+
"""
|
|
533
|
+
super().__init__(app)
|
|
534
|
+
self._password = password
|
|
535
|
+
self._exclude_paths = exclude_paths or ["/health", "/docs", "/redoc", "/openapi.json"]
|
|
536
|
+
|
|
537
|
+
async def dispatch(
|
|
538
|
+
self,
|
|
539
|
+
request: Request,
|
|
540
|
+
call_next: RequestResponseEndpoint,
|
|
541
|
+
) -> Response:
|
|
542
|
+
"""Authenticate request if password is set."""
|
|
543
|
+
# Skip if no password configured
|
|
544
|
+
if not self._password:
|
|
545
|
+
return await call_next(request)
|
|
546
|
+
|
|
547
|
+
# Skip excluded paths
|
|
548
|
+
if any(request.url.path.startswith(p) for p in self._exclude_paths):
|
|
549
|
+
return await call_next(request)
|
|
550
|
+
|
|
551
|
+
# Check authorization header
|
|
552
|
+
auth_header = request.headers.get("Authorization")
|
|
553
|
+
if not auth_header:
|
|
554
|
+
return self._unauthorized_response()
|
|
555
|
+
|
|
556
|
+
# Parse Basic auth
|
|
557
|
+
try:
|
|
558
|
+
scheme, credentials = auth_header.split(" ", 1)
|
|
559
|
+
if scheme.lower() != "basic":
|
|
560
|
+
return self._unauthorized_response()
|
|
561
|
+
|
|
562
|
+
import base64
|
|
563
|
+
decoded = base64.b64decode(credentials).decode("utf-8")
|
|
564
|
+
_, password = decoded.split(":", 1)
|
|
565
|
+
|
|
566
|
+
if password != self._password:
|
|
567
|
+
return self._unauthorized_response()
|
|
568
|
+
|
|
569
|
+
except (ValueError, UnicodeDecodeError):
|
|
570
|
+
return self._unauthorized_response()
|
|
571
|
+
|
|
572
|
+
return await call_next(request)
|
|
573
|
+
|
|
574
|
+
def _unauthorized_response(self) -> JSONResponse:
|
|
575
|
+
"""Create 401 Unauthorized response."""
|
|
576
|
+
return JSONResponse(
|
|
577
|
+
status_code=401,
|
|
578
|
+
content={
|
|
579
|
+
"success": False,
|
|
580
|
+
"error": {
|
|
581
|
+
"code": ErrorCode.AUTHENTICATION_REQUIRED.value,
|
|
582
|
+
"message": "Authentication required",
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
headers={"WWW-Authenticate": "Basic realm='truthound-dashboard'"},
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# =============================================================================
|
|
590
|
+
# Middleware Setup Helper
|
|
591
|
+
# =============================================================================
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def setup_middleware(app: Any) -> None:
|
|
595
|
+
"""Configure all middleware for the application.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
app: FastAPI application instance.
|
|
599
|
+
"""
|
|
600
|
+
from truthound_dashboard.config import get_settings
|
|
601
|
+
|
|
602
|
+
settings = get_settings()
|
|
603
|
+
|
|
604
|
+
# Add middleware in reverse order (last added = first executed)
|
|
605
|
+
|
|
606
|
+
# Request logging (always enabled)
|
|
607
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
608
|
+
|
|
609
|
+
# Rate limiting
|
|
610
|
+
rate_limit_config = RateLimitConfig(
|
|
611
|
+
requests_per_minute=120,
|
|
612
|
+
exclude_paths=["/health", "/docs", "/redoc", "/openapi.json", "/api/openapi.json"],
|
|
613
|
+
)
|
|
614
|
+
app.add_middleware(RateLimitMiddleware, config=rate_limit_config)
|
|
615
|
+
|
|
616
|
+
# Security headers
|
|
617
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
618
|
+
|
|
619
|
+
# Basic auth (optional)
|
|
620
|
+
if settings.auth_enabled and settings.auth_password:
|
|
621
|
+
app.add_middleware(
|
|
622
|
+
BasicAuthMiddleware,
|
|
623
|
+
password=settings.auth_password,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
logger.info("Middleware configured successfully")
|