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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .DS_Store
@@ -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()