Wine2You — Data-Exposure Controls Spec (anti-scrape)
Enforceable read-path scoping, bounded pagination, per-key row quotas and audit rules that prevent any API key from bulk-extracting the wine or offer tables.
Wine2You — Data-Exposure Controls Spec
Owner: Wine2You Platform · Status: accepted (2026-10-12) · Feeds: OpenAPI 3.2 contract (Phase A step 1)
Threat. A partner holding a valid entity-scoped key can, without any exploit, walk every paginated list endpoint until it holds the complete
wineandoffertables. Rate limits slow this but do not stop it. This spec defines the four controls that make partial data the maximum any key can obtain.
Controls (normative — the OpenAPI contract must encode all four)
C1 — Read-path row/field filter (primary defence)
Applied server-side to every list and detail read before serialization. A row is returned only if all hold:
| Gate | Rule | Source |
|---|---|---|
network_shareable |
offer.network_shareable == true, OR caller entity is the owning entity |
PIM §9 |
permissions |
caller entity satisfies offer.permissions (allowed_countries[], named_sellers[], default any) |
PIM §6/§12 |
| channel/tenant scope | caller's sales-channel ↔ stock-location links admit the row | PIM §9 |
- Applies to:
/v1/wines*,/v1/offers*,/v1/listings*,/v1/inventory*,/v1/orders*. - Entity-scoping (Unkey + OpenFGA) resolves who the caller is; C1 resolves what that entity may see. Both are required — one never substitutes for the other.
- A filtered row is omitted, not redacted (no placeholder/empty-object rows that leak existence).
C2 — Bounded pagination (no infinite walk)
- Cursor-based;
page_sizedefault 50, max 200;cursoropaque, single-use per query. GET /v1/inventory/network-snapshot(whole-network read) requires scoperead:network, which default partner keys do not carry. Absent scope →403.- List responses carry
page_size,next_cursor,has_more— never a total row count that advertises table size.
C3 — Per-key record quota (kills page-walking)
- Every key carries a records-returned budget per rolling period (default 50,000 rows / 24 h, per entity) in addition to the request-rate limit (requests/min).
- Exhausted →
429+Retry-After; the response must not reveal whether more rows exist. - Counters are per
entity_idso one partner's scrape cannot starve another's legitimate reads.
C4 — Audit & anomaly signal
- Every read logs:
key_id,entity_id,endpoint,rows_returned,cursor_span,ts. - Alert when a key's
rows_returnedexceeds N× (default 5×) its trailing 7-day average, or when a single key exceeds a configured % of total platform reads.
Enforcement points (where each control lives)
| Control | Enforced in | Depends on |
|---|---|---|
| C1 filter | API read layer (shared query middleware) | network_shareable, permissions, channel links in schema |
C2 pagination + read:network |
API router + OpenFGA model (can_read_network) |
Unkey key scopes |
| C3 quota | Unkey per-key quota + API middleware counter | Unkey key lifecycle (already deployed) |
| C4 audit | API read middleware → audit log + alert | log pipeline |
OpenAPI contract requirements (Phase A)
- Every list endpoint:
page_size,cursor,has_moreparameters;403when a scope is missing;429withRetry-Afteron quota exhaustion. securityscheme documents required scopes;read:networkmarked on/v1/inventory/network-snapshot.- Documented as
x-w2y-controls: [C1, C2, C3, C4]on each read operation so the contract is self-describing and testable.
Non-goals
- Not a substitute for encryption/access at rest, or for the CF Access edge gate on
api.wine2you.shop. - Not row-level write authorization (that is OpenFGA's
can_write_*— separate concern).
Verification checklist
- A key without
read:networkgets403onnetwork-snapshot. - A key page-walking
/v1/offershits429after quota, not the full table. - An offer with
network_shareable=falseis absent from every non-owner read. -
permissions.allowed_countriesexcludes a caller's country → row absent. - Audit rows present for each read; alert fires on a synthetic 6× volume spike.