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.
- sentinel_api/__init__.py +17 -0
- sentinel_api/config.py +228 -0
- sentinel_api/main.py +137 -0
- sentinel_api/models/security.py +7 -0
- sentinel_api/sdk_deployer.py +629 -0
- sentinel_api/services/auth.py +127 -0
- sentinel_api/services/dynamodb_rate_limiter.py +98 -0
- sentinel_api/services/memory_rate_limiter.py +57 -0
- sentinel_api/services/proxy.py +51 -0
- sentinel_api/services/rate_limiter.py +86 -0
- sentinel_api/services/rate_limiter_base.py +16 -0
- sentinel_api/services/request_logger.py +166 -0
- sentinel_api-1.0.3.dist-info/METADATA +286 -0
- sentinel_api-1.0.3.dist-info/RECORD +16 -0
- sentinel_api-1.0.3.dist-info/WHEEL +5 -0
- sentinel_api-1.0.3.dist-info/top_level.txt +1 -0
sentinel_api/__init__.py
ADDED
|
@@ -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
|