reqkey 0.1.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.
- reqkey/__init__.py +27 -0
- reqkey/_middleware.py +132 -0
- reqkey/asgi.py +612 -0
- reqkey/client.py +469 -0
- reqkey/django.py +628 -0
- reqkey/exceptions.py +42 -0
- reqkey/fastapi.py +5 -0
- reqkey/flask.py +5 -0
- reqkey/models.py +45 -0
- reqkey/py.typed +1 -0
- reqkey/wsgi.py +546 -0
- reqkey-0.1.0.dist-info/METADATA +679 -0
- reqkey-0.1.0.dist-info/RECORD +15 -0
- reqkey-0.1.0.dist-info/WHEEL +4 -0
- reqkey-0.1.0.dist-info/licenses/LICENSE +22 -0
reqkey/django.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Django-native ReqKey middleware for synchronous and asynchronous deployments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import zlib
|
|
10
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Protocol, cast
|
|
13
|
+
from urllib.parse import urlencode
|
|
14
|
+
|
|
15
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
18
|
+
from django.http.response import HttpResponseBase
|
|
19
|
+
|
|
20
|
+
from ._middleware import (
|
|
21
|
+
DEFAULT_ERROR_MESSAGES,
|
|
22
|
+
FailureMode,
|
|
23
|
+
KeyLocation,
|
|
24
|
+
KeyScheme,
|
|
25
|
+
Mode,
|
|
26
|
+
decision_headers,
|
|
27
|
+
denial,
|
|
28
|
+
excluded_header_names,
|
|
29
|
+
extract_credential,
|
|
30
|
+
filtered_headers,
|
|
31
|
+
path_matches,
|
|
32
|
+
validate_credit_cost,
|
|
33
|
+
validate_middleware_options,
|
|
34
|
+
)
|
|
35
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, AsyncReqKey, ReqKey
|
|
36
|
+
from .exceptions import ReqKeyConfigurationError, ReqKeyError
|
|
37
|
+
from .models import VerificationResult
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("reqkey")
|
|
40
|
+
|
|
41
|
+
MAX_RESPONSE_BODY_CHARACTERS = 1000
|
|
42
|
+
_MAX_CAPTURE_BYTES = MAX_RESPONSE_BODY_CHARACTERS * 4 + 4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncReqKeyLike(Protocol):
|
|
46
|
+
def verify(
|
|
47
|
+
self,
|
|
48
|
+
key: str,
|
|
49
|
+
*,
|
|
50
|
+
api_id: str | None = None,
|
|
51
|
+
credits: int = 1,
|
|
52
|
+
resource: str | None = None,
|
|
53
|
+
) -> VerificationResult: ...
|
|
54
|
+
|
|
55
|
+
def ingest(
|
|
56
|
+
self,
|
|
57
|
+
request_id: str | None = None,
|
|
58
|
+
*,
|
|
59
|
+
api_id: str | None = None,
|
|
60
|
+
method: str | None = None,
|
|
61
|
+
endpoint: str | None = None,
|
|
62
|
+
path: str | None = None,
|
|
63
|
+
status_code: int | None = None,
|
|
64
|
+
latency_ms: int | None = None,
|
|
65
|
+
client_ip: str | None = None,
|
|
66
|
+
user_agent: str | None = None,
|
|
67
|
+
user_id: str | None = None,
|
|
68
|
+
query_params: Mapping[str, Any] | None = None,
|
|
69
|
+
request_headers: Mapping[str, str] | None = None,
|
|
70
|
+
response_headers: Mapping[str, str] | None = None,
|
|
71
|
+
request_body: str | None = None,
|
|
72
|
+
response_body: str | None = None,
|
|
73
|
+
timestamp: datetime | str | None = None,
|
|
74
|
+
) -> None: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AsyncReqKeyLike(Protocol):
|
|
78
|
+
async def verify(
|
|
79
|
+
self,
|
|
80
|
+
key: str,
|
|
81
|
+
*,
|
|
82
|
+
api_id: str | None = None,
|
|
83
|
+
credits: int = 1,
|
|
84
|
+
resource: str | None = None,
|
|
85
|
+
) -> VerificationResult: ...
|
|
86
|
+
|
|
87
|
+
async def ingest(
|
|
88
|
+
self,
|
|
89
|
+
request_id: str | None = None,
|
|
90
|
+
*,
|
|
91
|
+
api_id: str | None = None,
|
|
92
|
+
method: str | None = None,
|
|
93
|
+
endpoint: str | None = None,
|
|
94
|
+
path: str | None = None,
|
|
95
|
+
status_code: int | None = None,
|
|
96
|
+
latency_ms: int | None = None,
|
|
97
|
+
client_ip: str | None = None,
|
|
98
|
+
user_agent: str | None = None,
|
|
99
|
+
user_id: str | None = None,
|
|
100
|
+
query_params: Mapping[str, Any] | None = None,
|
|
101
|
+
request_headers: Mapping[str, str] | None = None,
|
|
102
|
+
response_headers: Mapping[str, str] | None = None,
|
|
103
|
+
request_body: str | None = None,
|
|
104
|
+
response_body: str | None = None,
|
|
105
|
+
timestamp: datetime | str | None = None,
|
|
106
|
+
) -> None: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
SyncGetResponse = Callable[[HttpRequest], HttpResponseBase]
|
|
110
|
+
AsyncGetResponse = Callable[[HttpRequest], Awaitable[HttpResponseBase]]
|
|
111
|
+
CreditsResolver = Callable[[HttpRequest], int | Awaitable[int]]
|
|
112
|
+
ProtectionResolver = Callable[[HttpRequest], bool | Awaitable[bool]]
|
|
113
|
+
ConsumerKeyResolver = Callable[[HttpRequest], str | None | Awaitable[str | None]]
|
|
114
|
+
RequestIdResolver = Callable[[HttpRequest], str | None | Awaitable[str | None]]
|
|
115
|
+
PathResolver = Callable[[HttpRequest], str | Awaitable[str]]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReqKeyMiddleware:
|
|
119
|
+
"""Django middleware configured through the ``REQKEY`` Django setting."""
|
|
120
|
+
|
|
121
|
+
sync_capable = True
|
|
122
|
+
async_capable = True
|
|
123
|
+
|
|
124
|
+
def __init__(self, get_response: SyncGetResponse | AsyncGetResponse) -> None:
|
|
125
|
+
self.get_response = get_response
|
|
126
|
+
self._is_async = iscoroutinefunction(get_response)
|
|
127
|
+
if self._is_async:
|
|
128
|
+
markcoroutinefunction(self)
|
|
129
|
+
|
|
130
|
+
options = _django_options()
|
|
131
|
+
self.api_id = str(options.pop("api_id", ""))
|
|
132
|
+
self.mode = cast(Mode, options.pop("mode", "both"))
|
|
133
|
+
self.enabled = bool(options.pop("enabled", True))
|
|
134
|
+
self.key_location = cast(KeyLocation, options.pop("key_location", "header"))
|
|
135
|
+
self.key_name = str(options.pop("key_name", "X-API-Key"))
|
|
136
|
+
self.key_scheme = cast(KeyScheme, options.pop("key_scheme", "raw"))
|
|
137
|
+
self.get_consumer_key = cast(
|
|
138
|
+
ConsumerKeyResolver | None,
|
|
139
|
+
options.pop("get_consumer_key", None),
|
|
140
|
+
)
|
|
141
|
+
self.credits = cast(int | CreditsResolver, options.pop("credits", 1))
|
|
142
|
+
self.exclude_paths = tuple(cast(Iterable[str], options.pop("exclude_paths", ())))
|
|
143
|
+
self.skip_methods = frozenset(
|
|
144
|
+
method.upper()
|
|
145
|
+
for method in cast(Iterable[str], options.pop("skip_methods", ("OPTIONS",)))
|
|
146
|
+
)
|
|
147
|
+
self.should_protect = cast(
|
|
148
|
+
ProtectionResolver | None,
|
|
149
|
+
options.pop("should_protect", None),
|
|
150
|
+
)
|
|
151
|
+
self.request_id_resolver = cast(
|
|
152
|
+
RequestIdResolver | None,
|
|
153
|
+
options.pop("request_id_resolver", None),
|
|
154
|
+
)
|
|
155
|
+
self.path_resolver = cast(PathResolver | None, options.pop("path_resolver", None))
|
|
156
|
+
self.error_messages = {
|
|
157
|
+
**DEFAULT_ERROR_MESSAGES,
|
|
158
|
+
**dict(cast(Mapping[str, str], options.pop("error_messages", {}))),
|
|
159
|
+
}
|
|
160
|
+
self.capture_query_params = bool(options.pop("capture_query_params", False))
|
|
161
|
+
self.capture_request_headers = bool(options.pop("capture_request_headers", False))
|
|
162
|
+
self.capture_response_headers = bool(options.pop("capture_response_headers", False))
|
|
163
|
+
self.capture_response_body = bool(options.pop("capture_response_body", False))
|
|
164
|
+
self.capture_client_ip = bool(options.pop("capture_client_ip", False))
|
|
165
|
+
self.capture_user_agent = bool(options.pop("capture_user_agent", True))
|
|
166
|
+
self.excluded_headers = excluded_header_names(
|
|
167
|
+
self.key_name,
|
|
168
|
+
cast(Iterable[str], options.pop("excluded_headers", ())),
|
|
169
|
+
)
|
|
170
|
+
self.failure_mode = cast(FailureMode, options.pop("failure_mode", "closed"))
|
|
171
|
+
|
|
172
|
+
project_key = cast(str | None, options.pop("project_key", None))
|
|
173
|
+
root_key = cast(str | None, options.pop("root_key", None))
|
|
174
|
+
client = options.pop("client", None)
|
|
175
|
+
base_url = str(options.pop("base_url", DEFAULT_BASE_URL))
|
|
176
|
+
timeout = float(options.pop("timeout", DEFAULT_TIMEOUT_SECONDS))
|
|
177
|
+
if options:
|
|
178
|
+
unknown = ", ".join(sorted(options))
|
|
179
|
+
raise ReqKeyConfigurationError(f"Unknown REQKEY settings: {unknown}.")
|
|
180
|
+
|
|
181
|
+
validate_middleware_options(
|
|
182
|
+
api_id=self.api_id,
|
|
183
|
+
mode=self.mode,
|
|
184
|
+
key_location=self.key_location,
|
|
185
|
+
key_scheme=self.key_scheme,
|
|
186
|
+
failure_mode=self.failure_mode,
|
|
187
|
+
credits=self.credits,
|
|
188
|
+
)
|
|
189
|
+
if client is not None and (project_key is not None or root_key is not None):
|
|
190
|
+
raise ReqKeyConfigurationError("Pass CLIENT or PROJECT_KEY/ROOT_KEY, not both.")
|
|
191
|
+
if client is None:
|
|
192
|
+
if project_key is None and root_key is None:
|
|
193
|
+
project_key = os.getenv("REQKEY_PROJECT_KEY") or os.getenv(
|
|
194
|
+
"REQKEY_ROOT_KEY"
|
|
195
|
+
)
|
|
196
|
+
self.client: SyncReqKeyLike | AsyncReqKeyLike = (
|
|
197
|
+
AsyncReqKey(
|
|
198
|
+
project_key=project_key,
|
|
199
|
+
root_key=root_key,
|
|
200
|
+
base_url=base_url,
|
|
201
|
+
timeout=timeout,
|
|
202
|
+
)
|
|
203
|
+
if self._is_async
|
|
204
|
+
else ReqKey(
|
|
205
|
+
project_key=project_key,
|
|
206
|
+
root_key=root_key,
|
|
207
|
+
base_url=base_url,
|
|
208
|
+
timeout=timeout,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
self.client = cast(SyncReqKeyLike | AsyncReqKeyLike, client)
|
|
213
|
+
|
|
214
|
+
def __call__(
|
|
215
|
+
self,
|
|
216
|
+
request: HttpRequest,
|
|
217
|
+
) -> HttpResponseBase | Awaitable[HttpResponseBase]:
|
|
218
|
+
if self._is_async:
|
|
219
|
+
return self._acall(request)
|
|
220
|
+
return self._scall(request)
|
|
221
|
+
|
|
222
|
+
def _scall(self, request: HttpRequest) -> HttpResponseBase:
|
|
223
|
+
if not self.enabled or not self._applies_to_sync(request):
|
|
224
|
+
return cast(SyncGetResponse, self.get_response)(request)
|
|
225
|
+
|
|
226
|
+
decision: VerificationResult | None = None
|
|
227
|
+
validation_time_ms: float | None = None
|
|
228
|
+
client = cast(SyncReqKeyLike, self.client)
|
|
229
|
+
|
|
230
|
+
if self.mode in {"validate", "both"}:
|
|
231
|
+
consumer_key = self._consumer_key_sync(request)
|
|
232
|
+
if consumer_key is None:
|
|
233
|
+
return self._respond(401, "missing_api_key")
|
|
234
|
+
|
|
235
|
+
validation_started = time.perf_counter()
|
|
236
|
+
try:
|
|
237
|
+
decision = client.verify(
|
|
238
|
+
consumer_key,
|
|
239
|
+
api_id=self.api_id,
|
|
240
|
+
credits=self._credit_cost_sync(request),
|
|
241
|
+
resource=self._resource_path_sync(request),
|
|
242
|
+
)
|
|
243
|
+
validation_time_ms = (time.perf_counter() - validation_started) * 1000
|
|
244
|
+
except ReqKeyError as exc:
|
|
245
|
+
validation_time_ms = (time.perf_counter() - validation_started) * 1000
|
|
246
|
+
if self.failure_mode == "open":
|
|
247
|
+
request.reqkey_error = exc # type: ignore[attr-defined]
|
|
248
|
+
else:
|
|
249
|
+
logger.warning("ReqKey validation failed closed: %s", exc)
|
|
250
|
+
return self._respond(503, "reqkey_unavailable")
|
|
251
|
+
|
|
252
|
+
denied = self._denied_response(decision)
|
|
253
|
+
if denied is not None:
|
|
254
|
+
return denied
|
|
255
|
+
self._attach_decision(request, decision)
|
|
256
|
+
|
|
257
|
+
started = time.perf_counter()
|
|
258
|
+
try:
|
|
259
|
+
response = cast(SyncGetResponse, self.get_response)(request)
|
|
260
|
+
except Exception:
|
|
261
|
+
if self.mode != "validate":
|
|
262
|
+
self._ingest_sync(
|
|
263
|
+
client,
|
|
264
|
+
request=request,
|
|
265
|
+
decision=decision,
|
|
266
|
+
response=None,
|
|
267
|
+
status_code=500,
|
|
268
|
+
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
269
|
+
)
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
if self.mode != "validate":
|
|
273
|
+
self._ingest_sync(
|
|
274
|
+
client,
|
|
275
|
+
request=request,
|
|
276
|
+
decision=decision,
|
|
277
|
+
response=response,
|
|
278
|
+
status_code=response.status_code,
|
|
279
|
+
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
280
|
+
)
|
|
281
|
+
self._add_decision_headers(response, decision, validation_time_ms)
|
|
282
|
+
return response
|
|
283
|
+
|
|
284
|
+
async def _acall(self, request: HttpRequest) -> HttpResponseBase:
|
|
285
|
+
if not self.enabled or not await self._applies_to_async(request):
|
|
286
|
+
return await cast(AsyncGetResponse, self.get_response)(request)
|
|
287
|
+
|
|
288
|
+
decision: VerificationResult | None = None
|
|
289
|
+
validation_time_ms: float | None = None
|
|
290
|
+
client = cast(AsyncReqKeyLike, self.client)
|
|
291
|
+
|
|
292
|
+
if self.mode in {"validate", "both"}:
|
|
293
|
+
consumer_key = await self._consumer_key_async(request)
|
|
294
|
+
if consumer_key is None:
|
|
295
|
+
return self._respond(401, "missing_api_key")
|
|
296
|
+
|
|
297
|
+
validation_started = time.perf_counter()
|
|
298
|
+
try:
|
|
299
|
+
decision = await client.verify(
|
|
300
|
+
consumer_key,
|
|
301
|
+
api_id=self.api_id,
|
|
302
|
+
credits=await self._credit_cost_async(request),
|
|
303
|
+
resource=await self._resource_path_async(request),
|
|
304
|
+
)
|
|
305
|
+
validation_time_ms = (time.perf_counter() - validation_started) * 1000
|
|
306
|
+
except ReqKeyError as exc:
|
|
307
|
+
validation_time_ms = (time.perf_counter() - validation_started) * 1000
|
|
308
|
+
if self.failure_mode == "open":
|
|
309
|
+
request.reqkey_error = exc # type: ignore[attr-defined]
|
|
310
|
+
else:
|
|
311
|
+
logger.warning("ReqKey validation failed closed: %s", exc)
|
|
312
|
+
return self._respond(503, "reqkey_unavailable")
|
|
313
|
+
|
|
314
|
+
denied = self._denied_response(decision)
|
|
315
|
+
if denied is not None:
|
|
316
|
+
return denied
|
|
317
|
+
self._attach_decision(request, decision)
|
|
318
|
+
|
|
319
|
+
started = time.perf_counter()
|
|
320
|
+
try:
|
|
321
|
+
response = await cast(AsyncGetResponse, self.get_response)(request)
|
|
322
|
+
except Exception:
|
|
323
|
+
if self.mode != "validate":
|
|
324
|
+
await self._ingest_async(
|
|
325
|
+
client,
|
|
326
|
+
request=request,
|
|
327
|
+
decision=decision,
|
|
328
|
+
response=None,
|
|
329
|
+
status_code=500,
|
|
330
|
+
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
331
|
+
)
|
|
332
|
+
raise
|
|
333
|
+
|
|
334
|
+
if self.mode != "validate":
|
|
335
|
+
await self._ingest_async(
|
|
336
|
+
client,
|
|
337
|
+
request=request,
|
|
338
|
+
decision=decision,
|
|
339
|
+
response=response,
|
|
340
|
+
status_code=response.status_code,
|
|
341
|
+
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
342
|
+
)
|
|
343
|
+
self._add_decision_headers(response, decision, validation_time_ms)
|
|
344
|
+
return response
|
|
345
|
+
|
|
346
|
+
def _applies_to_sync(self, request: HttpRequest) -> bool:
|
|
347
|
+
if (request.method or "GET").upper() in self.skip_methods:
|
|
348
|
+
return False
|
|
349
|
+
if any(path_matches(request.path, pattern) for pattern in self.exclude_paths):
|
|
350
|
+
return False
|
|
351
|
+
if self.should_protect is None:
|
|
352
|
+
return True
|
|
353
|
+
value = self.should_protect(request)
|
|
354
|
+
if inspect.isawaitable(value):
|
|
355
|
+
raise ReqKeyConfigurationError(
|
|
356
|
+
"A synchronous Django stack requires synchronous middleware resolvers."
|
|
357
|
+
)
|
|
358
|
+
return bool(value)
|
|
359
|
+
|
|
360
|
+
async def _applies_to_async(self, request: HttpRequest) -> bool:
|
|
361
|
+
if (request.method or "GET").upper() in self.skip_methods:
|
|
362
|
+
return False
|
|
363
|
+
if any(path_matches(request.path, pattern) for pattern in self.exclude_paths):
|
|
364
|
+
return False
|
|
365
|
+
if self.should_protect is None:
|
|
366
|
+
return True
|
|
367
|
+
value = self.should_protect(request)
|
|
368
|
+
return bool(await value) if inspect.isawaitable(value) else bool(value)
|
|
369
|
+
|
|
370
|
+
def _consumer_key_sync(self, request: HttpRequest) -> str | None:
|
|
371
|
+
value = self._raw_consumer_key(request)
|
|
372
|
+
if inspect.isawaitable(value):
|
|
373
|
+
raise ReqKeyConfigurationError(
|
|
374
|
+
"A synchronous Django stack requires synchronous middleware resolvers."
|
|
375
|
+
)
|
|
376
|
+
return extract_credential(value, self.key_scheme)
|
|
377
|
+
|
|
378
|
+
async def _consumer_key_async(self, request: HttpRequest) -> str | None:
|
|
379
|
+
value = self._raw_consumer_key(request)
|
|
380
|
+
resolved = await value if inspect.isawaitable(value) else value
|
|
381
|
+
return extract_credential(resolved, self.key_scheme)
|
|
382
|
+
|
|
383
|
+
def _raw_consumer_key(
|
|
384
|
+
self,
|
|
385
|
+
request: HttpRequest,
|
|
386
|
+
) -> str | None | Awaitable[str | None]:
|
|
387
|
+
if self.get_consumer_key is not None:
|
|
388
|
+
return self.get_consumer_key(request)
|
|
389
|
+
if self.key_location == "header":
|
|
390
|
+
return request.headers.get(self.key_name)
|
|
391
|
+
if self.key_location == "query":
|
|
392
|
+
return request.GET.get(self.key_name)
|
|
393
|
+
return request.COOKIES.get(self.key_name)
|
|
394
|
+
|
|
395
|
+
def _credit_cost_sync(self, request: HttpRequest) -> int:
|
|
396
|
+
value = self.credits(request) if callable(self.credits) else self.credits
|
|
397
|
+
if inspect.isawaitable(value):
|
|
398
|
+
raise ReqKeyConfigurationError(
|
|
399
|
+
"A synchronous Django stack requires synchronous middleware resolvers."
|
|
400
|
+
)
|
|
401
|
+
return validate_credit_cost(value)
|
|
402
|
+
|
|
403
|
+
async def _credit_cost_async(self, request: HttpRequest) -> int:
|
|
404
|
+
value = self.credits(request) if callable(self.credits) else self.credits
|
|
405
|
+
resolved = await value if inspect.isawaitable(value) else value
|
|
406
|
+
return validate_credit_cost(resolved)
|
|
407
|
+
|
|
408
|
+
def _resource_path_sync(self, request: HttpRequest) -> str:
|
|
409
|
+
value = request.path if self.path_resolver is None else self.path_resolver(request)
|
|
410
|
+
if inspect.isawaitable(value):
|
|
411
|
+
raise ReqKeyConfigurationError(
|
|
412
|
+
"A synchronous Django stack requires synchronous middleware resolvers."
|
|
413
|
+
)
|
|
414
|
+
if not value:
|
|
415
|
+
raise ReqKeyConfigurationError("The path resolver returned an empty path.")
|
|
416
|
+
return value
|
|
417
|
+
|
|
418
|
+
async def _resource_path_async(self, request: HttpRequest) -> str:
|
|
419
|
+
value = request.path if self.path_resolver is None else self.path_resolver(request)
|
|
420
|
+
resolved = await value if inspect.isawaitable(value) else value
|
|
421
|
+
if not resolved:
|
|
422
|
+
raise ReqKeyConfigurationError("The path resolver returned an empty path.")
|
|
423
|
+
return resolved
|
|
424
|
+
|
|
425
|
+
def _ingest_sync(
|
|
426
|
+
self,
|
|
427
|
+
client: SyncReqKeyLike,
|
|
428
|
+
*,
|
|
429
|
+
request: HttpRequest,
|
|
430
|
+
decision: VerificationResult | None,
|
|
431
|
+
response: HttpResponseBase | None,
|
|
432
|
+
status_code: int,
|
|
433
|
+
latency_ms: int,
|
|
434
|
+
) -> None:
|
|
435
|
+
try:
|
|
436
|
+
client.ingest(
|
|
437
|
+
self._request_id_sync(request, decision),
|
|
438
|
+
**self._ingest_event(
|
|
439
|
+
request,
|
|
440
|
+
response,
|
|
441
|
+
status_code,
|
|
442
|
+
latency_ms,
|
|
443
|
+
self._resource_path_sync(request),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
except ReqKeyError as exc:
|
|
447
|
+
logger.warning("ReqKey analytics ingestion failed: %s", exc)
|
|
448
|
+
|
|
449
|
+
async def _ingest_async(
|
|
450
|
+
self,
|
|
451
|
+
client: AsyncReqKeyLike,
|
|
452
|
+
*,
|
|
453
|
+
request: HttpRequest,
|
|
454
|
+
decision: VerificationResult | None,
|
|
455
|
+
response: HttpResponseBase | None,
|
|
456
|
+
status_code: int,
|
|
457
|
+
latency_ms: int,
|
|
458
|
+
) -> None:
|
|
459
|
+
try:
|
|
460
|
+
await client.ingest(
|
|
461
|
+
await self._request_id_async(request, decision),
|
|
462
|
+
**self._ingest_event(
|
|
463
|
+
request,
|
|
464
|
+
response,
|
|
465
|
+
status_code,
|
|
466
|
+
latency_ms,
|
|
467
|
+
await self._resource_path_async(request),
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
except ReqKeyError as exc:
|
|
471
|
+
logger.warning("ReqKey analytics ingestion failed: %s", exc)
|
|
472
|
+
|
|
473
|
+
def _ingest_event(
|
|
474
|
+
self,
|
|
475
|
+
request: HttpRequest,
|
|
476
|
+
response: HttpResponseBase | None,
|
|
477
|
+
status_code: int,
|
|
478
|
+
latency_ms: int,
|
|
479
|
+
resource_path: str,
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
query_params, query = self._captured_query(request)
|
|
482
|
+
request_headers = dict(request.headers.items())
|
|
483
|
+
response_headers = dict(response.items()) if response is not None else {}
|
|
484
|
+
return {
|
|
485
|
+
"api_id": self.api_id,
|
|
486
|
+
"method": request.method,
|
|
487
|
+
"endpoint": resource_path,
|
|
488
|
+
"path": resource_path + (f"?{query}" if query else ""),
|
|
489
|
+
"status_code": status_code,
|
|
490
|
+
"latency_ms": latency_ms,
|
|
491
|
+
"client_ip": (
|
|
492
|
+
str(request.META.get("REMOTE_ADDR"))
|
|
493
|
+
if self.capture_client_ip and request.META.get("REMOTE_ADDR") is not None
|
|
494
|
+
else None
|
|
495
|
+
),
|
|
496
|
+
"user_agent": (
|
|
497
|
+
request.headers.get("user-agent") if self.capture_user_agent else None
|
|
498
|
+
),
|
|
499
|
+
"query_params": query_params,
|
|
500
|
+
"request_headers": (
|
|
501
|
+
filtered_headers(request_headers, self.excluded_headers)
|
|
502
|
+
if self.capture_request_headers
|
|
503
|
+
else None
|
|
504
|
+
),
|
|
505
|
+
"response_headers": (
|
|
506
|
+
filtered_headers(response_headers, self.excluded_headers)
|
|
507
|
+
if self.capture_response_headers
|
|
508
|
+
else None
|
|
509
|
+
),
|
|
510
|
+
"response_body": self._response_body(response),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
def _captured_query(self, request: HttpRequest) -> tuple[dict[str, str] | None, str]:
|
|
514
|
+
if not self.capture_query_params:
|
|
515
|
+
return None, ""
|
|
516
|
+
pairs = [
|
|
517
|
+
(key, value)
|
|
518
|
+
for key in request.GET
|
|
519
|
+
for value in request.GET.getlist(key)
|
|
520
|
+
if not (self.key_location == "query" and key == self.key_name)
|
|
521
|
+
]
|
|
522
|
+
return dict(pairs), urlencode(pairs)
|
|
523
|
+
|
|
524
|
+
def _response_body(self, response: HttpResponseBase | None) -> str | None:
|
|
525
|
+
if (
|
|
526
|
+
not self.capture_response_body
|
|
527
|
+
or response is None
|
|
528
|
+
or response.streaming
|
|
529
|
+
or not isinstance(response, HttpResponse)
|
|
530
|
+
):
|
|
531
|
+
return None
|
|
532
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
533
|
+
if content_type and not any(
|
|
534
|
+
marker in content_type
|
|
535
|
+
for marker in ("json", "text/", "xml", "javascript", "x-www-form-urlencoded")
|
|
536
|
+
):
|
|
537
|
+
return None
|
|
538
|
+
content = response.content
|
|
539
|
+
if response.headers.get("content-encoding", "").lower() == "gzip":
|
|
540
|
+
try:
|
|
541
|
+
decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
542
|
+
content = decompressor.decompress(content, _MAX_CAPTURE_BYTES)
|
|
543
|
+
except zlib.error:
|
|
544
|
+
logger.debug("Could not decompress a gzip response for ReqKey analytics.")
|
|
545
|
+
return None
|
|
546
|
+
else:
|
|
547
|
+
content = content[:_MAX_CAPTURE_BYTES]
|
|
548
|
+
return content.decode("utf-8", errors="replace")[:MAX_RESPONSE_BODY_CHARACTERS]
|
|
549
|
+
|
|
550
|
+
def _request_id_sync(
|
|
551
|
+
self,
|
|
552
|
+
request: HttpRequest,
|
|
553
|
+
decision: VerificationResult | None,
|
|
554
|
+
) -> str | None:
|
|
555
|
+
if decision is not None:
|
|
556
|
+
return decision.request_id
|
|
557
|
+
if self.request_id_resolver is not None:
|
|
558
|
+
value = self.request_id_resolver(request)
|
|
559
|
+
if inspect.isawaitable(value):
|
|
560
|
+
raise ReqKeyConfigurationError(
|
|
561
|
+
"A synchronous Django stack requires synchronous middleware resolvers."
|
|
562
|
+
)
|
|
563
|
+
return value or None
|
|
564
|
+
value = getattr(request, "reqkey_request_id", None)
|
|
565
|
+
return value if isinstance(value, str) else None
|
|
566
|
+
|
|
567
|
+
async def _request_id_async(
|
|
568
|
+
self,
|
|
569
|
+
request: HttpRequest,
|
|
570
|
+
decision: VerificationResult | None,
|
|
571
|
+
) -> str | None:
|
|
572
|
+
if decision is not None:
|
|
573
|
+
return decision.request_id
|
|
574
|
+
if self.request_id_resolver is not None:
|
|
575
|
+
value = self.request_id_resolver(request)
|
|
576
|
+
resolved = await value if inspect.isawaitable(value) else value
|
|
577
|
+
return resolved or None
|
|
578
|
+
value = getattr(request, "reqkey_request_id", None)
|
|
579
|
+
return value if isinstance(value, str) else None
|
|
580
|
+
|
|
581
|
+
def _denied_response(
|
|
582
|
+
self,
|
|
583
|
+
decision: VerificationResult | None,
|
|
584
|
+
) -> HttpResponseBase | None:
|
|
585
|
+
if decision is None or decision.valid:
|
|
586
|
+
return None
|
|
587
|
+
status_code, error = denial(decision)
|
|
588
|
+
response = self._respond(status_code, error)
|
|
589
|
+
if decision.retry_after is not None:
|
|
590
|
+
response["Retry-After"] = str(max(0, int(decision.retry_after)))
|
|
591
|
+
return response
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def _attach_decision(
|
|
595
|
+
request: HttpRequest,
|
|
596
|
+
decision: VerificationResult | None,
|
|
597
|
+
) -> None:
|
|
598
|
+
if decision is None:
|
|
599
|
+
return
|
|
600
|
+
request.reqkey = decision # type: ignore[attr-defined]
|
|
601
|
+
request.reqkey_request_id = decision.request_id # type: ignore[attr-defined]
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def _add_decision_headers(
|
|
605
|
+
response: HttpResponseBase,
|
|
606
|
+
decision: VerificationResult | None,
|
|
607
|
+
validation_time_ms: float | None,
|
|
608
|
+
) -> None:
|
|
609
|
+
for key, value in decision_headers(decision, validation_time_ms).items():
|
|
610
|
+
response[key] = value
|
|
611
|
+
|
|
612
|
+
def _respond(self, status_code: int, error: str) -> JsonResponse:
|
|
613
|
+
return JsonResponse(
|
|
614
|
+
{"error": error, "message": self.error_messages[error]},
|
|
615
|
+
status=status_code,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _django_options() -> dict[str, Any]:
|
|
620
|
+
raw = getattr(settings, "REQKEY", {})
|
|
621
|
+
if not isinstance(raw, Mapping):
|
|
622
|
+
raise ReqKeyConfigurationError("The Django REQKEY setting must be a mapping.")
|
|
623
|
+
return {str(key).lower(): value for key, value in raw.items()}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
DjangoReqKeyMiddleware = ReqKeyMiddleware
|
|
627
|
+
|
|
628
|
+
__all__ = ["DjangoReqKeyMiddleware", "ReqKeyMiddleware"]
|
reqkey/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions raised by the ReqKey SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReqKeyError(Exception):
|
|
9
|
+
"""Base class for all ReqKey SDK errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReqKeyConfigurationError(ReqKeyError, ValueError):
|
|
13
|
+
"""Raised when the SDK is configured with invalid values."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReqKeyTransportError(ReqKeyError):
|
|
17
|
+
"""Raised when ReqKey cannot be reached."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReqKeyTimeoutError(ReqKeyTransportError):
|
|
21
|
+
"""Raised when a request to ReqKey exceeds its configured timeout."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReqKeyAPIError(ReqKeyError):
|
|
25
|
+
"""Raised when ReqKey returns a response that is not an access decision."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
status_code: int,
|
|
32
|
+
body: dict[str, Any] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.body = body or {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReqKeyAuthenticationError(ReqKeyAPIError):
|
|
41
|
+
"""Raised when the project credential is missing, invalid, or expired."""
|
|
42
|
+
|
reqkey/fastapi.py
ADDED