Skip to content

Commit 92909a8

Browse files
author
Neo
committed
[phase-3] fix adversarial-pass P1: refunded/revoked are terminal — refund wins regardless of order
The blanket upsert set status unconditionally, so an out-of-order DID_RENEW — or the client's legitimate post-refund /verify report from the Transaction.updates listener — flipped a refunded lineage back to active. Now: - upsert_entitlement: terminal statuses are sticky in the ON CONFLICT clause (dates may refresh; status can never be lifted). - set_status: refuses to leave a terminal state (returns False + WARNING) unless the explicit allow_terminal_override — a deliberate, human-reviewed reversal — is passed. - REFUND_REVERSED is acknowledged and logged for manual review, never auto-reactivated (a replayed reversal after a second refund must not re-entitle). - 4 new ordering tests lock both attack vectors end to end. Suite: 582 passed.
1 parent 803992c commit 92909a8

3 files changed

Lines changed: 115 additions & 6 deletions

File tree

gateway/server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,15 @@ async def handle_iap_asn(self, request: web.Request) -> web.Response:
10321032
await self._entitlements.set_status(original_id, "revoked")
10331033
await self._entitlements.mark_transaction(
10341034
tx["transaction_id"], "revoked")
1035+
elif notification_type == "REFUND_REVERSED":
1036+
# Deliberately NOT auto-reactivated: terminal states are
1037+
# sticky (a replayed reversal after a second refund must not
1038+
# re-entitle). Rare enough to be a human decision — the
1039+
# operator flips it with set_status(allow_terminal_override).
1040+
logger.warning(
1041+
"iap asn: REFUND_REVERSED for lineage %s — terminal "
1042+
"state kept; manual review required to reactivate.",
1043+
original_id)
10351044
else:
10361045
logger.info("iap asn: %s acknowledged without a state flip",
10371046
notification_type or "<missing type>")

runtime/monetization/entitlement_store.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
flips the row to ``refunded``, revoke to ``revoked``, expiry to
1313
``expired``; a renewal writes the fresh ``expires_date`` back to ``active``.
1414
15+
``refunded``/``revoked`` are TERMINAL: Apple delivers notifications with no
16+
ordering guarantee, and after a refund the client still (correctly) reports
17+
the next renewal JWS to /iap/verify — so the blanket upsert refuses to lift a
18+
terminal status, and ``set_status`` refuses to leave one without an explicit
19+
``allow_terminal_override``. Refund wins regardless of arrival order.
20+
(A rare REFUND_REVERSED is deliberately NOT auto-reactivated — the ASN
21+
handler logs it for manual review; fail-closed means toward less
22+
entitlement, never more.)
23+
1524
This is the server-side entitlement PRIMITIVE — a queryable fact table.
1625
Nothing here gates existing routes; adopting it as a gate is a later,
1726
deliberate decision. Writes happen only from routes that verified the JWS
@@ -58,6 +67,9 @@
5867
#: statuses an entitlement row can hold.
5968
STATUSES = ("active", "expired", "refunded", "revoked")
6069

70+
#: terminal statuses — once set, only an explicit override can leave them.
71+
TERMINAL_STATUSES = ("refunded", "revoked")
72+
6173

6274
class EntitlementStore:
6375
"""CRUD over verified-IAP tables. Reads are fail-safe (empty on DB
@@ -145,7 +157,10 @@ async def upsert_entitlement(
145157
environment: str = "",
146158
) -> None:
147159
"""Create/refresh a subscription lineage row. A blank ``user_key``
148-
never overwrites a known one (webhooks carry no session)."""
160+
never overwrites a known one (webhooks carry no session), and a
161+
TERMINAL status (refunded/revoked) is sticky: a late-arriving renewal
162+
or a post-refund client /verify report can update dates but can NEVER
163+
lift the row back to active. Refund wins regardless of order."""
149164
await self._ensure_tables()
150165
if not original_transaction_id:
151166
raise ValueError("original_transaction_id required")
@@ -163,7 +178,9 @@ async def upsert_entitlement(
163178
ELSE iap_entitlements.user_key END,
164179
product_id = excluded.product_id,
165180
tier = excluded.tier,
166-
status = excluded.status,
181+
status = CASE WHEN iap_entitlements.status IN ('refunded', 'revoked')
182+
THEN iap_entitlements.status
183+
ELSE excluded.status END,
167184
purchase_date = excluded.purchase_date,
168185
expires_date = excluded.expires_date,
169186
environment = excluded.environment,
@@ -174,17 +191,29 @@ async def upsert_entitlement(
174191
)
175192

176193
async def set_status(self, original_transaction_id: str, status: str,
177-
*, expires_date: float | None = None) -> bool:
178-
"""Flip an entitlement row's status (ASN: refund/revoke/expire/renew).
179-
Returns False when no such lineage exists."""
194+
*, expires_date: float | None = None,
195+
allow_terminal_override: bool = False) -> bool:
196+
"""Flip an entitlement row's status (ASN: refund/revoke/expire).
197+
Returns False when no such lineage exists — or when the row sits in a
198+
TERMINAL state (refunded/revoked) and the new status would leave it;
199+
only an explicit ``allow_terminal_override=True`` (a deliberate,
200+
human-reviewed reversal) may do that."""
180201
await self._ensure_tables()
181202
if status not in STATUSES:
182203
raise ValueError(f"invalid status {status!r}")
183204
rows = await self._db.fetchall(
184-
"SELECT 1 FROM iap_entitlements WHERE original_transaction_id = ?",
205+
"SELECT status FROM iap_entitlements WHERE original_transaction_id = ?",
185206
(original_transaction_id,))
186207
if not rows:
187208
return False
209+
current = str(rows[0]["status"] if not isinstance(rows[0], tuple)
210+
else rows[0][0])
211+
if current in TERMINAL_STATUSES and status not in TERMINAL_STATUSES \
212+
and not allow_terminal_override:
213+
logger.warning(
214+
"iap: refused %s -> %s for lineage %s (terminal state is sticky)",
215+
current, status, original_transaction_id)
216+
return False
188217
if expires_date is None:
189218
await self._db.execute(
190219
"UPDATE iap_entitlements SET status = ?, updated_at = ? "

tests/test_iap_verify.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,39 @@ async def test_blank_user_key_never_unbinds(self, store):
299299
async def test_unknown_lineage_flip_returns_false(self, store):
300300
assert await store.set_status("missing", "refunded") is False
301301

302+
@pytest.mark.asyncio
303+
async def test_refund_is_terminal_against_late_upsert(self, store):
304+
"""Adversarial-pass P1: a renewal upsert arriving AFTER a refund
305+
(out-of-order ASN, or the client's post-refund /verify report) must
306+
NOT re-activate the lineage. Refund wins regardless of order."""
307+
now = time.time()
308+
await store.upsert_entitlement(
309+
original_transaction_id="o1", product_id=PRO, tier="pro",
310+
user_key="apple:victim", expires_date=now + 86400)
311+
await store.set_status("o1", "refunded")
312+
# The late renewal: blanket upsert with status='active' + fresh expiry.
313+
await store.upsert_entitlement(
314+
original_transaction_id="o1", product_id=PRO, tier="pro",
315+
status="active", expires_date=now + 30 * 86400)
316+
assert (await store.entitlement("o1"))["status"] == "refunded"
317+
assert await store.active_tier_for("apple:victim") is None
318+
319+
@pytest.mark.asyncio
320+
async def test_terminal_sticky_in_set_status(self, store):
321+
now = time.time()
322+
await store.upsert_entitlement(
323+
original_transaction_id="o1", product_id=PRO, tier="pro",
324+
user_key="apple:u1", expires_date=now + 86400)
325+
await store.set_status("o1", "revoked")
326+
# A later EXPIRED (or anything non-terminal) cannot soften revoked...
327+
assert await store.set_status("o1", "expired") is False
328+
assert await store.set_status("o1", "active") is False
329+
assert (await store.entitlement("o1"))["status"] == "revoked"
330+
# ...but a deliberate, human-reviewed override can.
331+
assert await store.set_status(
332+
"o1", "active", allow_terminal_override=True) is True
333+
assert (await store.entitlement("o1"))["status"] == "active"
334+
302335

303336
# ── Routes ──────────────────────────────────────────────────────────
304337

@@ -430,6 +463,44 @@ async def test_asn_expired_flips(self, iap_client, chain):
430463
ent = await iap_client._iap_store.entitlement("1000000000000001")
431464
assert ent["status"] == "expired"
432465

466+
@pytest.mark.asyncio
467+
async def test_refund_then_renew_stays_refunded(self, iap_client, chain):
468+
"""End-to-end lock on the adversarial-pass P1: REFUND, then an
469+
out-of-order DID_RENEW, then a post-refund client /verify report —
470+
the lineage must stay refunded through all three."""
471+
await iap_client.post("/api/v1/iap/verify",
472+
json={"signedTransaction": chain.sign(_tx_payload())})
473+
r = await iap_client.post(
474+
"/api/v1/iap/asn", json=_asn(chain, "REFUND", _tx_payload()))
475+
assert r.status == 200
476+
# Vector 1: stale DID_RENEW delivered after the refund.
477+
renewed = _tx_payload(
478+
transactionId="t-late-renew",
479+
expiresDate=int(time.time() * 1000) + 60 * 86400 * 1000)
480+
r = await iap_client.post(
481+
"/api/v1/iap/asn", json=_asn(chain, "DID_RENEW", renewed))
482+
assert r.status == 200
483+
ent = await iap_client._iap_store.entitlement("1000000000000001")
484+
assert ent["status"] == "refunded"
485+
# Vector 2: the client's updates listener reports the renewal JWS.
486+
r = await iap_client.post(
487+
"/api/v1/iap/verify", json={"signedTransaction": chain.sign(renewed)})
488+
assert r.status == 200
489+
ent = await iap_client._iap_store.entitlement("1000000000000001")
490+
assert ent["status"] == "refunded"
491+
492+
@pytest.mark.asyncio
493+
async def test_asn_refund_reversed_does_not_reactivate(self, iap_client, chain):
494+
await iap_client.post("/api/v1/iap/verify",
495+
json={"signedTransaction": chain.sign(_tx_payload())})
496+
await iap_client.post(
497+
"/api/v1/iap/asn", json=_asn(chain, "REFUND", _tx_payload()))
498+
r = await iap_client.post(
499+
"/api/v1/iap/asn", json=_asn(chain, "REFUND_REVERSED", _tx_payload()))
500+
assert r.status == 200 # acknowledged, logged for manual review
501+
ent = await iap_client._iap_store.entitlement("1000000000000001")
502+
assert ent["status"] == "refunded"
503+
433504
@pytest.mark.asyncio
434505
async def test_asn_spoof_untrusted_chain_401(self, iap_client):
435506
spoofer = Chain() # NOT in the trusted roots

0 commit comments

Comments
 (0)