Skip to content

Commit e6ae901

Browse files
authored
Merge pull request #6 from adamlaw669/feat/security-and-isolation-tests
Feat/security and isolation tests
2 parents 670508b + dbd82b1 commit e6ae901

5 files changed

Lines changed: 541 additions & 11 deletions

File tree

Lines changed: 200 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,222 @@
1-
"""Idempotency validation middleware for mutating API requests."""
1+
"""Idempotency middleware.
2+
3+
Requires an Idempotency-Key on mutating requests, and — for authenticated
4+
requests — stores the response the first time a key is seen and *replays* it
5+
when the same key is reused. This makes POST/PATCH/DELETE safe to retry:
6+
7+
- same key + same request body -> the stored response is replayed
8+
- same key + different body -> 409 (the key was reused for a new request)
9+
- key still in flight -> 409 (a concurrent request holds it)
10+
11+
The store lives in idempotency_records (one row per merchant+key+method+path).
12+
"""
213

314
from __future__ import annotations
415

5-
from collections.abc import Callable
16+
import hashlib
17+
import json
18+
import logging
619

720
from fastapi import Request
21+
from sqlalchemy import select
22+
from sqlalchemy.exc import IntegrityError
823
from starlette.middleware.base import BaseHTTPMiddleware
924
from starlette.responses import Response
1025

1126
from somba.api.errors import APIError, error_response
27+
from somba.db.models import IdempotencyRecord, IdempotencyRecordStatus, Merchant
28+
from somba.db.session import get_db
29+
from somba.security import parse_api_key, verify_api_key_secret
30+
31+
log = logging.getLogger(__name__)
1232

1333
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
1434
IDEMPOTENCY_EXEMPT = {"/v1/webhooks/nomba"}
1535

1636

1737
class IdempotencyMiddleware(BaseHTTPMiddleware):
18-
"""Require an idempotency key for mutating requests."""
38+
"""Require an idempotency key and replay stored responses on repeat keys."""
39+
40+
async def dispatch(self, request: Request, call_next):
41+
if request.method not in MUTATING_METHODS or request.url.path in IDEMPOTENCY_EXEMPT:
42+
return await call_next(request)
1943

20-
async def dispatch(self, request: Request, call_next: Callable[[Request], Response]) -> Response:
21-
if request.method in MUTATING_METHODS and request.url.path not in IDEMPOTENCY_EXEMPT:
22-
key = request.headers.get("Idempotency-Key", "").strip()
23-
if not key:
24-
error = APIError(
44+
key = request.headers.get("Idempotency-Key", "").strip()
45+
if not key:
46+
return error_response(
47+
APIError(
2548
code="missing_idempotency_key",
2649
message="Mutating requests require an Idempotency-Key header",
2750
status_code=400,
2851
param="Idempotency-Key",
2952
)
30-
return error_response(error)
31-
request.state.idempotency_key = key
53+
)
54+
request.state.idempotency_key = key
55+
56+
merchant_id = self._resolve_merchant_id(request)
57+
if merchant_id is None:
58+
# Unauthenticated / invalid key: let the route's auth return 401.
59+
return await call_next(request)
60+
61+
# Reading the body here is safe — Starlette's BaseHTTPMiddleware caches
62+
# it and replays it to the downstream handler.
63+
body = await request.body()
64+
request_hash = hashlib.sha256(
65+
request.method.encode() + b"|" + request.url.path.encode() + b"|" + body
66+
).hexdigest()
67+
lookup = (merchant_id, key, request.method, request.url.path)
68+
69+
# --- claim the key, or replay an existing result ---
70+
db, gen = self._session(request)
71+
try:
72+
existing = self._fetch(db, lookup)
73+
if existing is not None:
74+
replay = self._maybe_replay(existing, request_hash)
75+
if replay is not None:
76+
return replay
77+
# A 'failed' record: drop it and let this attempt retry.
78+
db.delete(existing)
79+
db.commit()
80+
81+
db.add(
82+
IdempotencyRecord(
83+
merchant_id=merchant_id,
84+
idempotency_key=key,
85+
method=request.method,
86+
path=request.url.path,
87+
request_hash=request_hash,
88+
status=IdempotencyRecordStatus.in_progress,
89+
)
90+
)
91+
try:
92+
db.commit()
93+
except IntegrityError:
94+
# A concurrent request claimed the same key first.
95+
db.rollback()
96+
return self._conflict("A request with this Idempotency-Key is already in progress")
97+
finally:
98+
self._close(gen)
99+
100+
# --- run the real handler ---
101+
try:
102+
response = await call_next(request)
103+
except Exception:
104+
# Handler blew up: release the key so the client can retry.
105+
self._release(request, lookup)
106+
raise
107+
108+
raw = b"".join([chunk async for chunk in response.body_iterator])
109+
110+
# --- persist the response (2xx) or release the key (anything else) ---
111+
db, gen = self._session(request)
112+
try:
113+
rec = self._fetch(db, lookup)
114+
if rec is not None:
115+
if 200 <= response.status_code < 300:
116+
rec.status = IdempotencyRecordStatus.completed
117+
rec.response_status = response.status_code
118+
rec.response_body = _safe_json(raw)
119+
else:
120+
db.delete(rec)
121+
db.commit()
122+
finally:
123+
self._close(gen)
124+
125+
headers = dict(response.headers)
126+
headers.pop("content-length", None)
127+
return Response(
128+
content=raw,
129+
status_code=response.status_code,
130+
headers=headers,
131+
media_type=response.media_type,
132+
)
133+
134+
# ------------------------------------------------------------------ helpers
135+
136+
def _session(self, request: Request):
137+
"""Resolve a DB session the same way FastAPI does — honouring overrides."""
138+
dep = request.app.dependency_overrides.get(get_db, get_db)
139+
gen = dep()
140+
return next(gen), gen
141+
142+
@staticmethod
143+
def _close(gen) -> None:
144+
try:
145+
next(gen)
146+
except StopIteration:
147+
pass
148+
149+
@staticmethod
150+
def _fetch(db, lookup):
151+
merchant_id, key, method, path = lookup
152+
return db.scalar(
153+
select(IdempotencyRecord).where(
154+
IdempotencyRecord.merchant_id == merchant_id,
155+
IdempotencyRecord.idempotency_key == key,
156+
IdempotencyRecord.method == method,
157+
IdempotencyRecord.path == path,
158+
)
159+
)
160+
161+
def _release(self, request: Request, lookup) -> None:
162+
db, gen = self._session(request)
163+
try:
164+
rec = self._fetch(db, lookup)
165+
if rec is not None:
166+
db.delete(rec)
167+
db.commit()
168+
finally:
169+
self._close(gen)
170+
171+
def _resolve_merchant_id(self, request: Request) -> int | None:
172+
header = request.headers.get("Authorization", "")
173+
if not header.startswith("Bearer "):
174+
return None
175+
token = header.removeprefix("Bearer ").strip()
176+
try:
177+
public_id, secret = parse_api_key(token)
178+
except ValueError:
179+
return None
180+
db, gen = self._session(request)
181+
try:
182+
merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id))
183+
if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash):
184+
return None
185+
return merchant.id
186+
finally:
187+
self._close(gen)
188+
189+
def _maybe_replay(self, existing: IdempotencyRecord, request_hash: str):
190+
if existing.status == IdempotencyRecordStatus.completed:
191+
if existing.request_hash == request_hash:
192+
body = existing.response_body
193+
content = json.dumps(body).encode() if body is not None else b""
194+
resp = Response(
195+
content=content,
196+
status_code=existing.response_status or 200,
197+
media_type="application/json",
198+
)
199+
resp.headers["Idempotency-Replayed"] = "true"
200+
return resp
201+
return self._conflict(
202+
"Idempotency-Key was reused with a different request body",
203+
code="idempotency_key_reuse",
204+
)
205+
if existing.status == IdempotencyRecordStatus.in_progress:
206+
return self._conflict("A request with this Idempotency-Key is already in progress")
207+
return None # failed -> caller drops it and retries
208+
209+
@staticmethod
210+
def _conflict(message: str, code: str = "idempotency_conflict"):
211+
return error_response(
212+
APIError(code=code, message=message, status_code=409, param="Idempotency-Key")
213+
)
214+
32215

33-
return await call_next(request)
216+
def _safe_json(raw: bytes):
217+
if not raw:
218+
return None
219+
try:
220+
return json.loads(raw)
221+
except ValueError:
222+
return None
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Idempotency end-to-end: a repeated Idempotency-Key replays the stored
2+
response instead of performing the action twice."""
3+
4+
from __future__ import annotations
5+
6+
from sqlalchemy import func, select
7+
8+
from somba.db.models import Customer
9+
10+
11+
def _headers(token: str, key: str) -> dict:
12+
return {"Authorization": f"Bearer {token}", "Idempotency-Key": key}
13+
14+
15+
def _customer_count(db) -> int:
16+
return db.scalar(select(func.count()).select_from(Customer))
17+
18+
19+
def test_same_key_same_body_replays_response(api_client, merchant_and_token, db):
20+
_, token = merchant_and_token
21+
key = "idem-fixed-123"
22+
body = {"email": "alice@gym.com", "name": "Alice"}
23+
24+
first = api_client.post("/v1/customers", json=body, headers=_headers(token, key))
25+
assert first.status_code == 201
26+
assert "Idempotency-Replayed" not in first.headers
27+
28+
second = api_client.post("/v1/customers", json=body, headers=_headers(token, key))
29+
assert second.status_code == 201
30+
assert second.headers.get("Idempotency-Replayed") == "true"
31+
32+
# Identical response, and only ONE customer was actually created.
33+
assert second.json() == first.json()
34+
assert _customer_count(db) == 1
35+
36+
37+
def test_same_key_different_body_conflicts(api_client, merchant_and_token, db):
38+
_, token = merchant_and_token
39+
key = "idem-fixed-456"
40+
41+
first = api_client.post(
42+
"/v1/customers", json={"email": "a@gym.com", "name": "A"}, headers=_headers(token, key)
43+
)
44+
assert first.status_code == 201
45+
46+
second = api_client.post(
47+
"/v1/customers", json={"email": "different@gym.com", "name": "B"}, headers=_headers(token, key)
48+
)
49+
assert second.status_code == 409
50+
assert second.json()["error"]["code"] == "idempotency_key_reuse"
51+
assert _customer_count(db) == 1 # the conflicting request created nothing
52+
53+
54+
def test_different_key_creates_distinct_resources(api_client, merchant_and_token, db):
55+
_, token = merchant_and_token
56+
body = {"email": "a@gym.com", "name": "A"}
57+
58+
api_client.post("/v1/customers", json=body, headers=_headers(token, "k1"))
59+
api_client.post("/v1/customers", json={"email": "b@gym.com", "name": "B"}, headers=_headers(token, "k2"))
60+
61+
assert _customer_count(db) == 2 # distinct keys -> two creations
62+
63+
64+
def test_missing_idempotency_key_rejected(api_client, merchant_and_token):
65+
_, token = merchant_and_token
66+
resp = api_client.post(
67+
"/v1/customers", json={"email": "x@gym.com"}, headers={"Authorization": f"Bearer {token}"}
68+
)
69+
assert resp.status_code == 400
70+
assert resp.json()["error"]["code"] == "missing_idempotency_key"

0 commit comments

Comments
 (0)