austial-security 0.2.0__tar.gz
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.
- austial_security-0.2.0/.gitignore +12 -0
- austial_security-0.2.0/PKG-INFO +8 -0
- austial_security-0.2.0/pyproject.toml +27 -0
- austial_security-0.2.0/src/austial/security/__init__.py +73 -0
- austial_security-0.2.0/src/austial/security/config.py +31 -0
- austial_security-0.2.0/src/austial/security/csrf_interceptor.py +94 -0
- austial_security-0.2.0/src/austial/security/decorators.py +40 -0
- austial_security-0.2.0/src/austial/security/envelope_encryptor.py +93 -0
- austial_security-0.2.0/src/austial/security/errors.py +31 -0
- austial_security-0.2.0/src/austial/security/idempotency_guard.py +124 -0
- austial_security-0.2.0/src/austial/security/idempotency_store.py +70 -0
- austial_security-0.2.0/src/austial/security/pii_redactor.py +132 -0
- austial_security-0.2.0/src/austial/security/rate_limit_guard.py +82 -0
- austial_security-0.2.0/src/austial/security/rate_limit_store.py +64 -0
- austial_security-0.2.0/src/austial/security/secure_headers_middleware.py +49 -0
- austial_security-0.2.0/src/austial/security/security_module.py +91 -0
- austial_security-0.2.0/test/security_spec.py +467 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: austial-security
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Security primitives -- secure headers, rate limiting, idempotency, CSRF, PII redaction, envelope encryption.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: austial-common
|
|
7
|
+
Requires-Dist: austial-core
|
|
8
|
+
Requires-Dist: cryptography>=43.0.0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "austial-security"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Security primitives -- secure headers, rate limiting, idempotency, CSRF, PII redaction, envelope encryption."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"austial-common",
|
|
8
|
+
"austial-core",
|
|
9
|
+
"cryptography>=43.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/austial"]
|
|
18
|
+
|
|
19
|
+
[tool.uv.sources]
|
|
20
|
+
austial-common = { workspace = true }
|
|
21
|
+
austial-core = { workspace = true }
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
source = "vcs"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.version.raw-options]
|
|
27
|
+
root = "../.."
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``austial.security`` -- baseline security primitives for Austial apps.
|
|
2
|
+
|
|
3
|
+
Ships:
|
|
4
|
+
|
|
5
|
+
- ``SecurityModule.for_root(config)`` -- DI wiring.
|
|
6
|
+
- ``SecureHeadersMiddleware`` -- CSP, HSTS, ``X-Content-Type-Options``,
|
|
7
|
+
``Referrer-Policy``, ``X-Frame-Options``, ``Permissions-Policy``.
|
|
8
|
+
- ``RateLimitGuard`` + ``@RateLimit(limit, period_seconds)`` -- fixed-window
|
|
9
|
+
rate limiting keyed on ``(client_ip, route)`` by default, with a pluggable
|
|
10
|
+
``RateLimitStore`` (in-memory reference impl included).
|
|
11
|
+
- ``IdempotencyKeyGuard`` -- ``Idempotency-Key`` replay/conflict handling for
|
|
12
|
+
``POST``/``PUT``/``PATCH``/``DELETE``, backed by a pluggable
|
|
13
|
+
``IdempotencyStore``.
|
|
14
|
+
- ``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
|
|
15
|
+
flows; skipped when a bearer token is present.
|
|
16
|
+
- ``PIIRedactor`` -- masks email, phone, PAN, Aadhaar, passport, IBAN and
|
|
17
|
+
Luhn-verified card numbers.
|
|
18
|
+
- ``EnvelopeEncryptor`` -- AES-256-GCM app-level column encryption with an
|
|
19
|
+
embedded KEK version.
|
|
20
|
+
|
|
21
|
+
Aligned with OWASP ASVS V5 (validation), V6 (secrets/crypto), V8 (data protection),
|
|
22
|
+
V9 (transport), and OWASP API Top-10 API4 (unrestricted resource consumption),
|
|
23
|
+
API6 (unrestricted access to business flows), API8 (security misconfiguration).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from austial.security.config import SecurityConfig
|
|
27
|
+
from austial.security.csrf_interceptor import (
|
|
28
|
+
CSRF_COOKIE_NAME,
|
|
29
|
+
CSRF_HEADER_NAME,
|
|
30
|
+
CsrfInterceptor,
|
|
31
|
+
)
|
|
32
|
+
from austial.security.decorators import RATE_LIMIT_METADATA, RateLimit, get_rate_limit
|
|
33
|
+
from austial.security.envelope_encryptor import EnvelopeEncryptor
|
|
34
|
+
from austial.security.errors import (
|
|
35
|
+
EncryptionKeyError,
|
|
36
|
+
IdempotencyConflictError,
|
|
37
|
+
RateLimitExceededError,
|
|
38
|
+
)
|
|
39
|
+
from austial.security.idempotency_guard import IdempotencyKeyGuard
|
|
40
|
+
from austial.security.idempotency_store import (
|
|
41
|
+
IdempotencyRecord,
|
|
42
|
+
IdempotencyStore,
|
|
43
|
+
InMemoryIdempotencyStore,
|
|
44
|
+
)
|
|
45
|
+
from austial.security.pii_redactor import PIIRedactor
|
|
46
|
+
from austial.security.rate_limit_guard import RateLimitGuard
|
|
47
|
+
from austial.security.rate_limit_store import InMemoryRateLimitStore, RateLimitStore
|
|
48
|
+
from austial.security.secure_headers_middleware import SecureHeadersMiddleware
|
|
49
|
+
from austial.security.security_module import SecurityModule
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"CSRF_COOKIE_NAME",
|
|
53
|
+
"CSRF_HEADER_NAME",
|
|
54
|
+
"CsrfInterceptor",
|
|
55
|
+
"EncryptionKeyError",
|
|
56
|
+
"EnvelopeEncryptor",
|
|
57
|
+
"IdempotencyConflictError",
|
|
58
|
+
"IdempotencyKeyGuard",
|
|
59
|
+
"IdempotencyRecord",
|
|
60
|
+
"IdempotencyStore",
|
|
61
|
+
"InMemoryIdempotencyStore",
|
|
62
|
+
"InMemoryRateLimitStore",
|
|
63
|
+
"PIIRedactor",
|
|
64
|
+
"RATE_LIMIT_METADATA",
|
|
65
|
+
"RateLimit",
|
|
66
|
+
"RateLimitExceededError",
|
|
67
|
+
"RateLimitGuard",
|
|
68
|
+
"RateLimitStore",
|
|
69
|
+
"SecureHeadersMiddleware",
|
|
70
|
+
"SecurityConfig",
|
|
71
|
+
"SecurityModule",
|
|
72
|
+
"get_rate_limit",
|
|
73
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""``SecurityConfig`` -- configuration passed to ``SecurityModule.for_root(...)``.
|
|
2
|
+
|
|
3
|
+
Held in its own module so ``EnvelopeEncryptor``/``RateLimitGuard``/etc. can read
|
|
4
|
+
individual fields without importing the module wiring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class SecurityConfig:
|
|
14
|
+
"""Every field has a safe default; only ``encryption_keys`` + ``active_encryption_key``
|
|
15
|
+
must be supplied for the ``EnvelopeEncryptor`` to be usable."""
|
|
16
|
+
|
|
17
|
+
csp: str = "default-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
|
|
18
|
+
hsts_max_age: int = 31536000
|
|
19
|
+
permissions_policy: str = "geolocation=(), microphone=(), camera=()"
|
|
20
|
+
encryption_keys: dict[str, bytes] = field(default_factory=dict)
|
|
21
|
+
active_encryption_key: str | None = None
|
|
22
|
+
default_rate_limit: tuple[int, int] = (60, 60)
|
|
23
|
+
idempotency_ttl_seconds: int = 86400
|
|
24
|
+
trusted_proxy_hops: int = 0
|
|
25
|
+
"""How many reverse proxies you operate in front of the app, for client-IP resolution.
|
|
26
|
+
|
|
27
|
+
``0`` (default): ``X-Forwarded-For`` is ignored and the socket peer is the client -- correct when
|
|
28
|
+
the app is exposed directly, and safe by default because the header is client-controlled. ``n > 0``:
|
|
29
|
+
the client is the ``n``-th entry counted from the *right* of ``X-Forwarded-For`` (each trusted proxy
|
|
30
|
+
appends the address it received the request from, so the right-most ``n`` entries are trustworthy
|
|
31
|
+
and everything to their left is attacker-controlled)."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``CsrfInterceptor`` -- double-submit cookie CSRF check for cookie-authenticated
|
|
2
|
+
flows.
|
|
3
|
+
|
|
4
|
+
The pattern:
|
|
5
|
+
|
|
6
|
+
- The server sets a random token in the ``csrf_token`` cookie at session start.
|
|
7
|
+
- Every state-changing request must echo the same token back in the
|
|
8
|
+
``X-CSRF-Token`` header.
|
|
9
|
+
- The interceptor compares the two values in constant time and rejects on any
|
|
10
|
+
mismatch.
|
|
11
|
+
|
|
12
|
+
Bearer-token flows are exempt: an ``Authorization: Bearer ...`` header cannot
|
|
13
|
+
be attached cross-origin without the caller possessing the credential outright,
|
|
14
|
+
which defeats the CSRF threat model. Read-only methods (``GET``/``HEAD``/
|
|
15
|
+
``OPTIONS``) are exempt as well; they should not carry side effects.
|
|
16
|
+
|
|
17
|
+
Raises ``ForbiddenException`` (HTTP 403) on a mismatch so a proxy or WAF can
|
|
18
|
+
distinguish CSRF failures from generic 401s.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hmac
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from austial.common.exceptions import ForbiddenException
|
|
27
|
+
from austial.common.guards.base import ExecutionContext
|
|
28
|
+
from austial.common.interceptors.base import CallHandler, NestInterceptor
|
|
29
|
+
|
|
30
|
+
CSRF_HEADER_NAME = "X-CSRF-Token"
|
|
31
|
+
CSRF_COOKIE_NAME = "csrf_token"
|
|
32
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CsrfInterceptor(NestInterceptor):
|
|
36
|
+
"""Enforce the double-submit cookie invariant before the handler runs.
|
|
37
|
+
|
|
38
|
+
Product code sets the cookie itself (usually at login); this interceptor is
|
|
39
|
+
the read-side check."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
header_name: str = CSRF_HEADER_NAME,
|
|
45
|
+
cookie_name: str = CSRF_COOKIE_NAME,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._header = header_name
|
|
48
|
+
self._cookie = cookie_name
|
|
49
|
+
|
|
50
|
+
async def intercept(self, context: ExecutionContext, next: CallHandler) -> Any:
|
|
51
|
+
request = context.get_request()
|
|
52
|
+
method = getattr(request, "method", "GET").upper()
|
|
53
|
+
|
|
54
|
+
if method in _SAFE_METHODS:
|
|
55
|
+
return await next.handle()
|
|
56
|
+
|
|
57
|
+
# Bearer-authenticated request => skip CSRF (not the relevant threat model).
|
|
58
|
+
auth = _header(getattr(request, "headers", {}) or {}, "Authorization")
|
|
59
|
+
if auth and auth.lower().startswith("bearer "):
|
|
60
|
+
return await next.handle()
|
|
61
|
+
|
|
62
|
+
header_token = _header(getattr(request, "headers", {}) or {}, self._header)
|
|
63
|
+
cookie_token = _cookie(request, self._cookie)
|
|
64
|
+
|
|
65
|
+
if not header_token or not cookie_token:
|
|
66
|
+
raise ForbiddenException("CSRF token missing")
|
|
67
|
+
|
|
68
|
+
if not hmac.compare_digest(header_token, cookie_token):
|
|
69
|
+
raise ForbiddenException("CSRF token mismatch")
|
|
70
|
+
|
|
71
|
+
return await next.handle()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
value = headers.get(name)
|
|
77
|
+
except AttributeError:
|
|
78
|
+
return None
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
try:
|
|
82
|
+
return headers.get(name.lower())
|
|
83
|
+
except AttributeError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cookie(request: Any, name: str) -> str | None:
|
|
88
|
+
cookies = getattr(request, "cookies", None)
|
|
89
|
+
if cookies is None:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return cookies.get(name)
|
|
93
|
+
except AttributeError:
|
|
94
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Decorators: ``@RateLimit(limit, period_seconds)``.
|
|
2
|
+
|
|
3
|
+
Attaches per-route metadata that ``RateLimitGuard`` reads to override
|
|
4
|
+
``SecurityConfig.default_rate_limit`` for that handler.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from austial.core.metadata import get_metadata, set_metadata
|
|
12
|
+
|
|
13
|
+
RATE_LIMIT_METADATA = "austial:security:rate_limit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def RateLimit(limit: int, period_seconds: int) -> Callable: # noqa: N802 - decorator naming
|
|
17
|
+
"""Override the rate-limit budget for a single handler or controller.
|
|
18
|
+
|
|
19
|
+
``limit`` is the maximum number of requests inside a fixed window of
|
|
20
|
+
``period_seconds`` seconds. Raises ``ValueError`` at decoration time on a
|
|
21
|
+
non-positive value so misconfiguration surfaces at import."""
|
|
22
|
+
|
|
23
|
+
if limit <= 0:
|
|
24
|
+
raise ValueError("RateLimit limit must be positive")
|
|
25
|
+
if period_seconds <= 0:
|
|
26
|
+
raise ValueError("RateLimit period_seconds must be positive")
|
|
27
|
+
|
|
28
|
+
def decorator(target):
|
|
29
|
+
set_metadata(RATE_LIMIT_METADATA, (limit, period_seconds))(target)
|
|
30
|
+
return target
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_rate_limit(target, default: tuple[int, int]) -> tuple[int, int]:
|
|
36
|
+
"""Return the rate-limit tuple attached to ``target`` (or ``default``)."""
|
|
37
|
+
value = get_metadata(RATE_LIMIT_METADATA, target, None)
|
|
38
|
+
if value is None:
|
|
39
|
+
return default
|
|
40
|
+
return value
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""``EnvelopeEncryptor`` -- app-level column encryption with AES-256-GCM.
|
|
2
|
+
|
|
3
|
+
The wire format is a URL-safe base64 encoding of::
|
|
4
|
+
|
|
5
|
+
b"v1:<key_version>:" || nonce (12 bytes) || ciphertext || tag (16 bytes)
|
|
6
|
+
|
|
7
|
+
The key version prefix lets a rotation swap the active KEK without a bulk
|
|
8
|
+
re-encrypt: old rows keep decrypting with their embedded version, new writes
|
|
9
|
+
use ``SecurityConfig.active_encryption_key``.
|
|
10
|
+
|
|
11
|
+
Callers pass a ``dict[str, bytes]`` of ``version -> 32-byte key``. A key that
|
|
12
|
+
is not exactly 32 bytes is rejected at construction (``EncryptionKeyError``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from cryptography.exceptions import InvalidTag
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
22
|
+
|
|
23
|
+
from austial.common.decorators.injectable import Injectable
|
|
24
|
+
from austial.security.errors import EncryptionKeyError
|
|
25
|
+
|
|
26
|
+
_VERSION_PREFIX = b"v1:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@Injectable()
|
|
30
|
+
class EnvelopeEncryptor:
|
|
31
|
+
"""Symmetric app-level encryption for individual column values.
|
|
32
|
+
|
|
33
|
+
``encrypt`` writes with ``active_key``. ``decrypt`` reads whichever version
|
|
34
|
+
the ciphertext advertises so historical rows stay readable across a key
|
|
35
|
+
rotation."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, keys: dict[str, bytes], active_key: str | None) -> None:
|
|
38
|
+
if not keys:
|
|
39
|
+
raise EncryptionKeyError("At least one encryption key must be supplied")
|
|
40
|
+
if active_key is None:
|
|
41
|
+
raise EncryptionKeyError("active_encryption_key must be supplied")
|
|
42
|
+
if active_key not in keys:
|
|
43
|
+
raise EncryptionKeyError(f"active_encryption_key '{active_key}' is not present in keys")
|
|
44
|
+
for version, material in keys.items():
|
|
45
|
+
if not isinstance(material, (bytes, bytearray)) or len(material) != 32:
|
|
46
|
+
raise EncryptionKeyError(f"Key '{version}' must be exactly 32 bytes (AES-256)")
|
|
47
|
+
self._keys: dict[str, bytes] = {v: bytes(m) for v, m in keys.items()}
|
|
48
|
+
self._active_key = active_key
|
|
49
|
+
|
|
50
|
+
def encrypt(self, plaintext: str) -> str:
|
|
51
|
+
if not isinstance(plaintext, str):
|
|
52
|
+
raise TypeError("plaintext must be str")
|
|
53
|
+
aesgcm = AESGCM(self._keys[self._active_key])
|
|
54
|
+
nonce = os.urandom(12)
|
|
55
|
+
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
|
56
|
+
header = _VERSION_PREFIX + self._active_key.encode("ascii") + b":"
|
|
57
|
+
blob = header + nonce + ciphertext_with_tag
|
|
58
|
+
return base64.urlsafe_b64encode(blob).decode("ascii")
|
|
59
|
+
|
|
60
|
+
def decrypt(self, ciphertext: str) -> str:
|
|
61
|
+
try:
|
|
62
|
+
blob = base64.urlsafe_b64decode(ciphertext.encode("ascii"))
|
|
63
|
+
except (ValueError, TypeError) as exc:
|
|
64
|
+
raise EncryptionKeyError("Ciphertext is not valid base64") from exc
|
|
65
|
+
|
|
66
|
+
if not blob.startswith(_VERSION_PREFIX):
|
|
67
|
+
raise EncryptionKeyError("Ciphertext is missing the version prefix")
|
|
68
|
+
remainder = blob[len(_VERSION_PREFIX) :]
|
|
69
|
+
try:
|
|
70
|
+
version_end = remainder.index(b":")
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise EncryptionKeyError("Ciphertext version delimiter not found") from exc
|
|
73
|
+
|
|
74
|
+
version = remainder[:version_end].decode("ascii", errors="replace")
|
|
75
|
+
payload = remainder[version_end + 1 :]
|
|
76
|
+
if len(payload) < 12 + 16: # nonce + minimum-sized tag
|
|
77
|
+
raise EncryptionKeyError("Ciphertext payload is truncated")
|
|
78
|
+
|
|
79
|
+
key = self._keys.get(version)
|
|
80
|
+
if key is None:
|
|
81
|
+
raise EncryptionKeyError(f"Unknown encryption key version '{version}'")
|
|
82
|
+
|
|
83
|
+
nonce, ciphertext_bytes = payload[:12], payload[12:]
|
|
84
|
+
aesgcm = AESGCM(key)
|
|
85
|
+
try:
|
|
86
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext_bytes, None)
|
|
87
|
+
except InvalidTag as exc:
|
|
88
|
+
raise EncryptionKeyError("Ciphertext failed authentication") from exc
|
|
89
|
+
return plaintext.decode("utf-8")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def active_key(self) -> str:
|
|
93
|
+
return self._active_key
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Security-specific exceptions.
|
|
2
|
+
|
|
3
|
+
Extend Austial's typed HTTP exceptions so ``AllExceptionsFilter`` maps them to
|
|
4
|
+
the correct status codes without a translation layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from austial.common.exceptions import ConflictException, TooManyRequestsException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimitExceededError(TooManyRequestsException):
|
|
13
|
+
"""Raised by ``RateLimitGuard`` when a caller exceeds their allowance
|
|
14
|
+
inside the current fixed window (HTTP 429)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Rate limit exceeded", *, retry_after: int | None = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyConflictError(ConflictException):
|
|
22
|
+
"""Raised by ``IdempotencyKeyGuard`` when the same ``Idempotency-Key`` is
|
|
23
|
+
replayed with a different request body inside the TTL (HTTP 409)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "Idempotency key reused with different request"):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EncryptionKeyError(ValueError):
|
|
30
|
+
"""Raised by ``EnvelopeEncryptor`` when the active KEK is missing, a key
|
|
31
|
+
length is wrong, or a stored ciphertext references an unknown key version."""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""``IdempotencyKeyGuard`` -- enforces ``Idempotency-Key`` on write methods.
|
|
2
|
+
|
|
3
|
+
For ``POST``/``PUT``/``PATCH``/``DELETE`` the guard reads the header
|
|
4
|
+
``Idempotency-Key``:
|
|
5
|
+
|
|
6
|
+
- Missing header: guard is a no-op (allow through). Product code can require it
|
|
7
|
+
by decorating the route with a companion validator.
|
|
8
|
+
- Header present, no cached record: allow through; the framework's response
|
|
9
|
+
interceptor stores the outcome (product code calls
|
|
10
|
+
``store.put(IdempotencyRecord(...))`` from a middleware/interceptor once the
|
|
11
|
+
handler returns).
|
|
12
|
+
- Header present, cached record with the same body hash: replay the cached
|
|
13
|
+
response by short-circuiting the pipeline. This is done by attaching the
|
|
14
|
+
record to ``request.state.idempotent_replay``; the response interceptor is
|
|
15
|
+
expected to render it.
|
|
16
|
+
- Header present, cached record with a *different* body hash: 409 conflict.
|
|
17
|
+
|
|
18
|
+
Read-only methods (``GET``/``HEAD``/``OPTIONS``) are allowed unconditionally --
|
|
19
|
+
they are already idempotent by definition and rarely mutate state.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from austial.common.decorators.injectable import Injectable
|
|
29
|
+
from austial.common.guards.base import CanActivate, ExecutionContext
|
|
30
|
+
from austial.security.errors import IdempotencyConflictError
|
|
31
|
+
from austial.security.idempotency_store import IdempotencyStore
|
|
32
|
+
|
|
33
|
+
_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@Injectable()
|
|
37
|
+
class IdempotencyKeyGuard(CanActivate):
|
|
38
|
+
def __init__(self, store: IdempotencyStore) -> None:
|
|
39
|
+
self._store = store
|
|
40
|
+
|
|
41
|
+
async def can_activate(self, context: ExecutionContext) -> bool:
|
|
42
|
+
request = context.get_request()
|
|
43
|
+
method = getattr(request, "method", "GET").upper()
|
|
44
|
+
if method not in _WRITE_METHODS:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
headers = getattr(request, "headers", {}) or {}
|
|
48
|
+
key = _header(headers, "Idempotency-Key")
|
|
49
|
+
if not key:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
request_hash = await self._hash_request(request, method)
|
|
53
|
+
cached = self._store.get(key)
|
|
54
|
+
if cached is None:
|
|
55
|
+
# cache miss: allow through; product code stores after handler runs
|
|
56
|
+
request.state.idempotency_key = key
|
|
57
|
+
request.state.idempotency_request_hash = request_hash
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
if cached.request_hash != request_hash:
|
|
61
|
+
raise IdempotencyConflictError(
|
|
62
|
+
f"Idempotency-Key '{key}' has already been used with a different request payload"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# cache hit: signal replay
|
|
66
|
+
request.state.idempotent_replay = cached
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
async def _hash_request(self, request: Any, method: str) -> str:
|
|
70
|
+
path = getattr(getattr(request, "url", None), "path", "") or getattr(request, "path", "/")
|
|
71
|
+
body_bytes = await _body(request)
|
|
72
|
+
digest = hashlib.sha256()
|
|
73
|
+
digest.update(method.encode("ascii"))
|
|
74
|
+
digest.update(b"\0")
|
|
75
|
+
digest.update(path.encode("utf-8"))
|
|
76
|
+
digest.update(b"\0")
|
|
77
|
+
digest.update(body_bytes)
|
|
78
|
+
return digest.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
82
|
+
try:
|
|
83
|
+
value = headers.get(name)
|
|
84
|
+
except AttributeError:
|
|
85
|
+
return None
|
|
86
|
+
if value:
|
|
87
|
+
return value
|
|
88
|
+
try:
|
|
89
|
+
return headers.get(name.lower())
|
|
90
|
+
except AttributeError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _body(request: Any) -> bytes:
|
|
95
|
+
"""Read the request body. Starlette exposes ``await request.body()``; a test
|
|
96
|
+
double may attach ``body_bytes`` directly or provide a dict."""
|
|
97
|
+
# cached body written by prior guards/middleware
|
|
98
|
+
cached = getattr(getattr(request, "state", None), "raw_body", None)
|
|
99
|
+
if isinstance(cached, (bytes, bytearray)):
|
|
100
|
+
return bytes(cached)
|
|
101
|
+
|
|
102
|
+
body_attr = getattr(request, "body", None)
|
|
103
|
+
if callable(body_attr):
|
|
104
|
+
result = body_attr()
|
|
105
|
+
if hasattr(result, "__await__"):
|
|
106
|
+
result = await result
|
|
107
|
+
if isinstance(result, (bytes, bytearray)):
|
|
108
|
+
return bytes(result)
|
|
109
|
+
if isinstance(result, str):
|
|
110
|
+
return result.encode("utf-8")
|
|
111
|
+
if isinstance(body_attr, (bytes, bytearray)):
|
|
112
|
+
return bytes(body_attr)
|
|
113
|
+
if isinstance(body_attr, str):
|
|
114
|
+
return body_attr.encode("utf-8")
|
|
115
|
+
|
|
116
|
+
body_bytes = getattr(request, "body_bytes", None)
|
|
117
|
+
if isinstance(body_bytes, (bytes, bytearray)):
|
|
118
|
+
return bytes(body_bytes)
|
|
119
|
+
|
|
120
|
+
payload = getattr(request, "json_body", None)
|
|
121
|
+
if payload is not None:
|
|
122
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
123
|
+
|
|
124
|
+
return b""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""``IdempotencyStore`` protocol + ``InMemoryIdempotencyStore`` reference impl.
|
|
2
|
+
|
|
3
|
+
An idempotency record captures the response produced for a mutation so the
|
|
4
|
+
guard can replay it verbatim if the client retries with the same
|
|
5
|
+
``Idempotency-Key``. Records expire after ``ttl_seconds``; a replay with a
|
|
6
|
+
different request body while the record is still alive is a conflict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from austial.common.decorators.injectable import Injectable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class IdempotencyRecord:
|
|
21
|
+
"""Snapshot of a response bound to an ``Idempotency-Key``."""
|
|
22
|
+
|
|
23
|
+
key: str
|
|
24
|
+
request_hash: str
|
|
25
|
+
status_code: int
|
|
26
|
+
body: Any
|
|
27
|
+
stored_at: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class IdempotencyStore(Protocol):
|
|
32
|
+
"""Pluggable backend for ``IdempotencyKeyGuard``."""
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
35
|
+
"""Return the record for ``key`` if it is still within TTL, else ``None``."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def put(self, record: IdempotencyRecord) -> None:
|
|
39
|
+
"""Persist a record (overwriting any earlier record for the same key)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@Injectable()
|
|
44
|
+
class InMemoryIdempotencyStore:
|
|
45
|
+
"""Thread-safe, process-local record cache with lazy TTL expiry."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, ttl_seconds: int = 86400) -> None:
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._ttl = ttl_seconds
|
|
50
|
+
self._records: dict[str, IdempotencyRecord] = {}
|
|
51
|
+
|
|
52
|
+
def get(self, key: str) -> IdempotencyRecord | None:
|
|
53
|
+
now = time.time()
|
|
54
|
+
with self._lock:
|
|
55
|
+
record = self._records.get(key)
|
|
56
|
+
if record is None:
|
|
57
|
+
return None
|
|
58
|
+
if now - record.stored_at > self._ttl:
|
|
59
|
+
self._records.pop(key, None)
|
|
60
|
+
return None
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
def put(self, record: IdempotencyRecord) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._records[record.key] = record
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None:
|
|
68
|
+
"""Clear all records. Useful in tests."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._records.clear()
|