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.
Files changed (62) hide show
  1. truthound_dashboard/__init__.py +11 -0
  2. truthound_dashboard/__main__.py +6 -0
  3. truthound_dashboard/api/__init__.py +15 -0
  4. truthound_dashboard/api/deps.py +153 -0
  5. truthound_dashboard/api/drift.py +179 -0
  6. truthound_dashboard/api/error_handlers.py +287 -0
  7. truthound_dashboard/api/health.py +78 -0
  8. truthound_dashboard/api/history.py +62 -0
  9. truthound_dashboard/api/middleware.py +626 -0
  10. truthound_dashboard/api/notifications.py +561 -0
  11. truthound_dashboard/api/profile.py +52 -0
  12. truthound_dashboard/api/router.py +83 -0
  13. truthound_dashboard/api/rules.py +277 -0
  14. truthound_dashboard/api/schedules.py +329 -0
  15. truthound_dashboard/api/schemas.py +136 -0
  16. truthound_dashboard/api/sources.py +229 -0
  17. truthound_dashboard/api/validations.py +125 -0
  18. truthound_dashboard/cli.py +226 -0
  19. truthound_dashboard/config.py +132 -0
  20. truthound_dashboard/core/__init__.py +264 -0
  21. truthound_dashboard/core/base.py +185 -0
  22. truthound_dashboard/core/cache.py +479 -0
  23. truthound_dashboard/core/connections.py +331 -0
  24. truthound_dashboard/core/encryption.py +409 -0
  25. truthound_dashboard/core/exceptions.py +627 -0
  26. truthound_dashboard/core/logging.py +488 -0
  27. truthound_dashboard/core/maintenance.py +542 -0
  28. truthound_dashboard/core/notifications/__init__.py +56 -0
  29. truthound_dashboard/core/notifications/base.py +390 -0
  30. truthound_dashboard/core/notifications/channels.py +557 -0
  31. truthound_dashboard/core/notifications/dispatcher.py +453 -0
  32. truthound_dashboard/core/notifications/events.py +155 -0
  33. truthound_dashboard/core/notifications/service.py +744 -0
  34. truthound_dashboard/core/sampling.py +626 -0
  35. truthound_dashboard/core/scheduler.py +311 -0
  36. truthound_dashboard/core/services.py +1531 -0
  37. truthound_dashboard/core/truthound_adapter.py +659 -0
  38. truthound_dashboard/db/__init__.py +67 -0
  39. truthound_dashboard/db/base.py +108 -0
  40. truthound_dashboard/db/database.py +196 -0
  41. truthound_dashboard/db/models.py +732 -0
  42. truthound_dashboard/db/repository.py +237 -0
  43. truthound_dashboard/main.py +309 -0
  44. truthound_dashboard/schemas/__init__.py +150 -0
  45. truthound_dashboard/schemas/base.py +96 -0
  46. truthound_dashboard/schemas/drift.py +118 -0
  47. truthound_dashboard/schemas/history.py +74 -0
  48. truthound_dashboard/schemas/profile.py +91 -0
  49. truthound_dashboard/schemas/rule.py +199 -0
  50. truthound_dashboard/schemas/schedule.py +88 -0
  51. truthound_dashboard/schemas/schema.py +121 -0
  52. truthound_dashboard/schemas/source.py +138 -0
  53. truthound_dashboard/schemas/validation.py +192 -0
  54. truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
  55. truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
  56. truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
  57. truthound_dashboard/static/index.html +15 -0
  58. truthound_dashboard/static/mockServiceWorker.js +349 -0
  59. truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
  60. truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
  61. truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
  62. 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")