sentinel-api 1.0.3__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.
@@ -0,0 +1,17 @@
1
+ """SentinelAPI package exports."""
2
+
3
+ from sentinel_api.sdk_deployer import (
4
+ deploy_foundation,
5
+ deploy_full,
6
+ deploy_stack,
7
+ teardown_foundation,
8
+ teardown_stack,
9
+ )
10
+
11
+ __all__ = [
12
+ "deploy_foundation",
13
+ "deploy_full",
14
+ "deploy_stack",
15
+ "teardown_foundation",
16
+ "teardown_stack",
17
+ ]
sentinel_api/config.py ADDED
@@ -0,0 +1,228 @@
1
+ """Central application configuration for SentinelAPI.
2
+
3
+ Environment variables are mapped into a typed settings object and exposed
4
+ through the module-level `settings` singleton.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from pydantic import AliasChoices, Field, field_validator, model_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+ _ENV_PREFIX = "SENTINEL_API_"
14
+
15
+ _PRESET_DEFAULTS: dict[str, dict[str, str]] = {
16
+ "cost": {
17
+ "FARGATE_CPU": "256",
18
+ "FARGATE_MEMORY_MIB": "512",
19
+ "ECS_DESIRED_COUNT": "1",
20
+ "LOG_RETENTION_DAYS": "7",
21
+ "REQUEST_TIMEOUT_SECONDS": "10",
22
+ "RATE_LIMIT_CAPACITY": "100",
23
+ "RATE_LIMIT_REFILL_RATE": "1.0",
24
+ "ANOMALY_THRESHOLD": "8.0",
25
+ "ANOMALY_MIN_REQUESTS": "40",
26
+ "ANOMALY_AUTO_BLOCK": "true",
27
+ "ANOMALY_AUTO_BLOCK_TTL_SECONDS": "3600",
28
+ "JWT_ALGORITHM": "HS256",
29
+ },
30
+ "performance": {
31
+ "FARGATE_CPU": "1024",
32
+ "FARGATE_MEMORY_MIB": "2048",
33
+ "ECS_DESIRED_COUNT": "2",
34
+ "LOG_RETENTION_DAYS": "30",
35
+ "REQUEST_TIMEOUT_SECONDS": "8",
36
+ "RATE_LIMIT_CAPACITY": "300",
37
+ "RATE_LIMIT_REFILL_RATE": "5.0",
38
+ "ANOMALY_THRESHOLD": "5.0",
39
+ "ANOMALY_MIN_REQUESTS": "60",
40
+ "ANOMALY_AUTO_BLOCK": "true",
41
+ "ANOMALY_AUTO_BLOCK_TTL_SECONDS": "3600",
42
+ "JWT_ALGORITHM": "HS256",
43
+ },
44
+ }
45
+
46
+
47
+ def _read_env_file(path: Path) -> dict[str, str]:
48
+ """Parse a dotenv-style file into key/value pairs."""
49
+ if not path.exists():
50
+ return {}
51
+
52
+ values: dict[str, str] = {}
53
+ with path.open(encoding="utf-8") as handle:
54
+ for raw_line in handle:
55
+ line = raw_line.strip()
56
+ if not line or line.startswith("#") or "=" not in line:
57
+ continue
58
+ key, value = line.split("=", 1)
59
+ values[key.strip()] = value.strip().strip("'").strip('"')
60
+ return values
61
+
62
+
63
+ def _apply_layered_env(project_root: Path | None = None) -> None:
64
+ """Load `.env` while respecting existing OS env vars."""
65
+ root = project_root or Path(__file__).resolve().parents[2]
66
+ external_env_keys = set(os.environ.keys())
67
+
68
+ base_values = _read_env_file(root / ".env")
69
+ for key, value in base_values.items():
70
+ if key not in external_env_keys:
71
+ os.environ[key] = value
72
+
73
+
74
+ def _normalize_optimize_for(raw_value: str | None) -> str:
75
+ normalized = (raw_value or "cost").strip().lower()
76
+ if normalized in _PRESET_DEFAULTS:
77
+ return normalized
78
+ return "cost"
79
+
80
+
81
+ def _apply_optimization_defaults() -> None:
82
+ """Apply optimization preset defaults without overriding explicit values."""
83
+ optimize_for = _normalize_optimize_for(
84
+ os.environ.get(f"{_ENV_PREFIX}OPTIMIZE_FOR") or os.environ.get("OPTIMIZE_FOR")
85
+ )
86
+
87
+ prefixed_optimize_key = f"{_ENV_PREFIX}OPTIMIZE_FOR"
88
+ if prefixed_optimize_key not in os.environ and "OPTIMIZE_FOR" not in os.environ:
89
+ os.environ[prefixed_optimize_key] = optimize_for
90
+
91
+ preset_values = _PRESET_DEFAULTS[optimize_for]
92
+ for key, value in preset_values.items():
93
+ prefixed_key = f"{_ENV_PREFIX}{key}"
94
+ # Preserve explicit shell/env/.env values (prefixed or legacy key names).
95
+ if prefixed_key in os.environ or key in os.environ:
96
+ continue
97
+ os.environ[prefixed_key] = value
98
+
99
+
100
+ _apply_layered_env()
101
+ _apply_optimization_defaults()
102
+
103
+
104
+ def _env_aliases(name: str) -> AliasChoices:
105
+ """Accept prefixed env var first, with legacy fallback for compatibility."""
106
+ return AliasChoices(f"{_ENV_PREFIX}{name}", name)
107
+
108
+
109
+ class Settings(BaseSettings):
110
+ """Pydantic settings model for runtime and infrastructure tuning."""
111
+
112
+ model_config = SettingsConfigDict(extra="ignore")
113
+
114
+ app_name: str = Field(default="SentinelAPI", validation_alias=_env_aliases("APP_NAME"))
115
+ log_level: str = Field(default="INFO", validation_alias=_env_aliases("LOG_LEVEL"))
116
+
117
+ optimize_for: str = Field(default="cost", validation_alias=_env_aliases("OPTIMIZE_FOR"))
118
+ fargate_cpu: int = Field(default=256, validation_alias=_env_aliases("FARGATE_CPU"))
119
+
120
+ @field_validator("optimize_for")
121
+ @classmethod
122
+ def _validate_optimize_for(cls, value: str) -> str:
123
+ normalized = value.strip().lower()
124
+ if normalized not in _PRESET_DEFAULTS:
125
+ raise ValueError("SENTINEL_API_OPTIMIZE_FOR must be one of: cost, performance")
126
+ return normalized
127
+
128
+ fargate_memory_mib: int = Field(
129
+ default=512,
130
+ validation_alias=_env_aliases("FARGATE_MEMORY_MIB"),
131
+ )
132
+ ecs_desired_count: int = Field(default=1, validation_alias=_env_aliases("ECS_DESIRED_COUNT"))
133
+ log_retention_days: int = Field(default=7, validation_alias=_env_aliases("LOG_RETENTION_DAYS"))
134
+
135
+ upstream_base_url: str = Field(
136
+ default="",
137
+ validation_alias=_env_aliases("UPSTREAM_BASE_URL"),
138
+ )
139
+ request_timeout_seconds: float = Field(
140
+ default=10.0,
141
+ validation_alias=_env_aliases("REQUEST_TIMEOUT_SECONDS"),
142
+ )
143
+
144
+ jwt_algorithm: str = Field(default="HS256", validation_alias=_env_aliases("JWT_ALGORITHM"))
145
+ jwt_issuer: str | None = Field(default=None, validation_alias=_env_aliases("JWT_ISSUER"))
146
+ jwt_audience: str | None = Field(default=None, validation_alias=_env_aliases("JWT_AUDIENCE"))
147
+ jwt_jwks_url: str | None = Field(default=None, validation_alias=_env_aliases("JWT_JWKS_URL"))
148
+ jwt_jwks_cache_ttl_seconds: int = Field(
149
+ default=300,
150
+ validation_alias=_env_aliases("JWT_JWKS_CACHE_TTL_SECONDS"),
151
+ )
152
+ jwt_secret_key: str | None = Field(
153
+ default=None,
154
+ validation_alias=_env_aliases("JWT_SECRET_KEY"),
155
+ )
156
+ jwt_public_key: str | None = Field(
157
+ default=None,
158
+ validation_alias=_env_aliases("JWT_PUBLIC_KEY"),
159
+ )
160
+
161
+ redis_url: str = Field(
162
+ default="redis://localhost:6379/0",
163
+ validation_alias=_env_aliases("REDIS_URL"),
164
+ )
165
+ rate_limit_capacity: int = Field(
166
+ default=100,
167
+ validation_alias=_env_aliases("RATE_LIMIT_CAPACITY"),
168
+ )
169
+ rate_limit_refill_rate: float = Field(
170
+ default=1.0,
171
+ validation_alias=_env_aliases("RATE_LIMIT_REFILL_RATE"),
172
+ )
173
+ blocklist_prefix: str = Field(
174
+ default="sentinel:blocklist",
175
+ validation_alias=_env_aliases("BLOCKLIST_PREFIX"),
176
+ )
177
+
178
+ aws_region: str = Field(default="us-west-2", validation_alias=_env_aliases("AWS_REGION"))
179
+ ddb_table_name: str = Field(
180
+ default="sentinel-request-logs",
181
+ validation_alias=_env_aliases("DDB_TABLE_NAME"),
182
+ )
183
+ ddb_aggregate_table_name: str = Field(
184
+ default="sentinel-traffic-agg",
185
+ validation_alias=_env_aliases("DDB_AGGREGATE_TABLE_NAME"),
186
+ )
187
+ ddb_rate_limit_table_name: str = Field(
188
+ default="sentinel-rate-limits",
189
+ validation_alias=_env_aliases("DDB_RATE_LIMIT_TABLE_NAME"),
190
+ )
191
+ ddb_blocklist_table_name: str = Field(
192
+ default="sentinel-blocklist",
193
+ validation_alias=_env_aliases("DDB_BLOCKLIST_TABLE_NAME"),
194
+ )
195
+ sns_topic_arn: str | None = Field(default=None, validation_alias=_env_aliases("SNS_TOPIC_ARN"))
196
+
197
+ anomaly_auto_block: bool = Field(
198
+ default=True,
199
+ validation_alias=_env_aliases("ANOMALY_AUTO_BLOCK"),
200
+ )
201
+ anomaly_auto_block_ttl_seconds: int = Field(
202
+ default=3600,
203
+ validation_alias=_env_aliases("ANOMALY_AUTO_BLOCK_TTL_SECONDS"),
204
+ )
205
+ anomaly_threshold: float = Field(
206
+ default=8.0,
207
+ validation_alias=_env_aliases("ANOMALY_THRESHOLD"),
208
+ )
209
+ anomaly_min_requests: int = Field(
210
+ default=40,
211
+ validation_alias=_env_aliases("ANOMALY_MIN_REQUESTS"),
212
+ )
213
+
214
+ @model_validator(mode="after")
215
+ def _validate_auth_configuration(self) -> "Settings":
216
+ has_secret = bool((self.jwt_secret_key or "").strip())
217
+ has_public = bool((self.jwt_public_key or "").strip())
218
+ has_jwks = bool((self.jwt_jwks_url or "").strip())
219
+ if not (has_secret or has_public or has_jwks):
220
+ raise ValueError(
221
+ "JWT verification is not configured. Define at least one of: "
222
+ "SENTINEL_API_JWT_SECRET_KEY, SENTINEL_API_JWT_PUBLIC_KEY, "
223
+ "SENTINEL_API_JWT_JWKS_URL."
224
+ )
225
+ return self
226
+
227
+
228
+ settings = Settings()
sentinel_api/main.py ADDED
@@ -0,0 +1,137 @@
1
+ """FastAPI application entrypoint for SentinelAPI.
2
+
3
+ This module wires auth, rate limiting, proxying, and request logging into a
4
+ single API-edge service with one architecture and tunable runtime knobs.
5
+ """
6
+
7
+ import logging
8
+ import time
9
+
10
+ import httpx
11
+ from fastapi import Depends, FastAPI, HTTPException, Request, Response
12
+ from redis.asyncio import Redis
13
+
14
+ from sentinel_api.config import settings
15
+ from sentinel_api.models.security import AuthContext
16
+ from sentinel_api.services.auth import AuthError, JWTAuthenticator
17
+ from sentinel_api.services.proxy import forward_request
18
+ from sentinel_api.services.rate_limiter import RateLimiter as RedisRateLimiter
19
+ from sentinel_api.services.rate_limiter_base import RateLimiterProtocol
20
+ from sentinel_api.services.request_logger import build_request_logger
21
+
22
+ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
23
+ logger = logging.getLogger(__name__)
24
+
25
+ app = FastAPI(title=settings.app_name)
26
+
27
+
28
+ async def _build_rate_limiter() -> tuple[RateLimiterProtocol, Redis | None]:
29
+ """Instantiate Redis-backed rate limiter."""
30
+ redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
31
+ rate_limiter = RedisRateLimiter(redis_client=redis_client, settings=settings)
32
+ return rate_limiter, redis_client
33
+
34
+
35
+ @app.on_event("startup")
36
+ async def startup() -> None:
37
+ """Initialize shared clients/services and store them on app state."""
38
+ rate_limiter, redis_client = await _build_rate_limiter()
39
+ app.state.http_client = httpx.AsyncClient(timeout=settings.request_timeout_seconds)
40
+ app.state.redis_client = redis_client
41
+ app.state.rate_limiter = rate_limiter
42
+ app.state.request_logger = build_request_logger(settings=settings)
43
+ app.state.authenticator = JWTAuthenticator(settings=settings)
44
+
45
+ logger.info(
46
+ "SentinelAPI startup rate_limit_backend=redis request_log_backend=dynamodb",
47
+ )
48
+
49
+
50
+ @app.on_event("shutdown")
51
+ async def shutdown() -> None:
52
+ """Close network clients gracefully on process shutdown."""
53
+ await app.state.http_client.aclose()
54
+ if app.state.redis_client is not None:
55
+ await app.state.redis_client.aclose()
56
+
57
+
58
+ def extract_bearer_token(request: Request) -> str:
59
+ """Extract Bearer token from Authorization header or raise 401."""
60
+ header = request.headers.get("authorization", "")
61
+ if not header.lower().startswith("bearer "):
62
+ raise HTTPException(status_code=401, detail="Missing Bearer token")
63
+ return header[7:].strip()
64
+
65
+
66
+ async def authenticate(request: Request) -> AuthContext:
67
+ """Validate JWT and return authenticated user context."""
68
+ token = extract_bearer_token(request)
69
+ try:
70
+ return app.state.authenticator.decode_token(token)
71
+ except AuthError as exc:
72
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
73
+
74
+
75
+ @app.get("/health")
76
+ async def health() -> dict[str, str]:
77
+ """Liveness endpoint that also returns active backend wiring info."""
78
+ return {
79
+ "status": "ok",
80
+ "rateLimitBackend": "redis",
81
+ "requestLogBackend": "dynamodb",
82
+ }
83
+
84
+
85
+ @app.get("/auth/verify")
86
+ async def auth_verify(auth: AuthContext = Depends(authenticate)) -> dict[str, str | bool | None]:
87
+ """Return auth context for quick JWT verification testing."""
88
+ return {
89
+ "authenticated": True,
90
+ "userId": auth.user_id,
91
+ "tokenId": auth.token_id,
92
+ }
93
+
94
+
95
+ @app.api_route("/proxy/{full_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
96
+ async def proxy(
97
+ full_path: str,
98
+ request: Request,
99
+ auth: AuthContext = Depends(authenticate),
100
+ ) -> Response:
101
+ """Authenticate, rate-limit, proxy upstream call, and persist request telemetry."""
102
+ start = time.perf_counter()
103
+ allowed, tokens_remaining = await app.state.rate_limiter.allow_request(auth.user_id)
104
+
105
+ if not allowed:
106
+ detail = "User blocked" if tokens_remaining is None else "Rate limit exceeded"
107
+ raise HTTPException(status_code=429, detail=detail)
108
+
109
+ response = await forward_request(
110
+ request=request,
111
+ client=app.state.http_client,
112
+ upstream_base_url=settings.upstream_base_url,
113
+ )
114
+
115
+ latency_ms = (time.perf_counter() - start) * 1000
116
+ client_host = request.client.host if request.client else "unknown"
117
+ user_agent = request.headers.get("user-agent", "unknown")
118
+
119
+ try:
120
+ await app.state.request_logger.log_request(
121
+ user_id=auth.user_id,
122
+ endpoint=f"/{full_path}",
123
+ latency_ms=latency_ms,
124
+ status_code=response.status_code,
125
+ ip_address=client_host,
126
+ user_agent=user_agent,
127
+ )
128
+ except Exception as exc: # noqa: BLE001
129
+ logger.warning(
130
+ "Request logging failed for user=%s endpoint=/%s: %s",
131
+ auth.user_id,
132
+ full_path,
133
+ exc,
134
+ )
135
+
136
+ response.headers["x-rate-limit-remaining"] = str(int(tokens_remaining or 0))
137
+ return response
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class AuthContext:
6
+ user_id: str
7
+ token_id: str | None