Short answer: bind tenant and auction scope to the authenticated connection, re-check it at subscription and delivery, and make every event replayable by sequence. A live auction dashboard should trust a server-issued token scope, never a room name supplied by the browser.
That sounds strict because the expensive failure is quiet. A bidder can be authorized to submit an offer while the fan-out path leaks another tenant's bids. In a marketplace session, one accepted bid can become hundreds of browser updates in under a second. Isolation has to survive that multiplication.
How should realtime API boundaries isolate tenants in a live auction dashboard?
Draw the boundary four times. The connection gateway authenticates the principal and derives tenant identity from trusted claims. The subscription check decides whether that principal may watch a specific auction. The publisher stamps the event with the tenant and auction that own the state transition. The delivery gateway compares event scope with connection scope immediately before writing bytes to a socket or event stream.
Each check answers a different question: who is this, may they watch, who owns this change, and does it still belong on this connection? A single check at login cannot answer the last three after a role change, reconnect, or queue delay.
Keep the trusted scope server-side. The browser may request an opaque auction ID, but it must not choose tenant_id in a JSON body and expect that value to become authority. Resolve the auction inside the authenticated tenant, issue a short-lived token containing the approved scope, and repeat authorization on reconnect. An old connection is not proof that a new role is still valid.
The final comparison is intentionally boring:
from dataclasses import dataclass
@dataclass(frozen=True)
class Scope:
tenant_id: str
auction_id: str
@dataclass(frozen=True)
class Event:
event_id: str
tenant_id: str
auction_id: str
sequence: int
def authorize_delivery(scope: Scope, event: Event, last_sequence: int) -> None:
if (event.tenant_id, event.auction_id) != (scope.tenant_id, scope.auction_id):
raise PermissionError("TENANT_MISMATCH")
if event.sequence <= last_sequence:
raise ValueError("STALE_EVENT")
Log a mismatch as a security decision, with the principal, policy version, and event ID. Do not relabel and retry it. A queue is an untrusted boundary because subscriptions and permissions can change while an event waits.
What belongs in a tenant-scoped event envelope and token?
The envelope should carry an immutable event ID, tenant ID, auction ID, an auction-local sequence, event type, schema version, and creation time. Display payloads are data; they do not get to redefine the envelope's scope. An auction-local sequence avoids making one busy tenant's traffic shape a global ordering concern.
The token should be narrower than the user's general account permission. Include the tenant, allowed auction IDs or an audience claim, expiry, issuer, and a policy version. Keep bid submission and read subscription scopes separate. A moderator who can read an auction does not automatically gain permission to place a bid, and a service token that can publish events should not be accepted by a browser gateway.
I once treated a room slug as the authorization key and found the mistake through a 403 spike: a renamed auction produced a cache miss, then a fallback looked up the room globally. The fix was not another slug format. It was requiring (tenant_id, auction_id) at every lookup and making a missing tuple fail closed. Small identifiers. Large blast radius.
That incident also changed how I inspect traces. The gateway log had the user ID and the room slug, the queue log had an event ID, and the browser log had a sequence number; none of the three records carried the same tenant assertion, so correlating a suspicious delivery took a manual search across hosts. We now propagate a non-secret authorization decision ID alongside the immutable event ID, record the resolved tenant and auction at admission, and retain the policy version used for the decision. When a permission is revoked between subscription and delivery, the trace shows the old decision, the new policy version, and the exact queue age. That level of detail is tedious to add before launch, but it turns a plausible cross-tenant report into a bounded query instead of a day of guesswork. It also keeps support staff from asking engineers to paste raw tokens into tickets.
Boring is good.
On acceptance, check the auction version, write the bid, advance the auction sequence, and append an outbox record in one transaction. Return success to the bidder from that committed decision. Dashboard delivery is separate and can be at-least-once; consumers deduplicate by event ID and reject sequence regressions. Exactly-once business effects come from atomic state, an outbox, idempotent consumers, and snapshot reconciliation, not from a transport promise.
Which fan-out guarantees and failure tests matter most?
Record three outcomes independently: the bid was accepted, the event was published, and a connection observed it. Their identifiers should join, but their retention differs. The auction ledger is authoritative. The outbox and relay ledger explain publication. A delivery cursor or acknowledgement explains a browser's progress.
If sequence 184 is committed and 185 arrives first, do not render 185 and hope the gap is cosmetic. Hold a bounded reorder window, request replay from 184, or force a tenant-scoped snapshot when the gap exceeds policy. The right window depends on latency and storage; the invariant does not: arrival order cannot replace domain order.
Backpressure is isolation work. Bound queues by tenant or another enforceable fairness unit, measure queue age as well as depth, and define the full-queue behavior. Dropping arbitrary bid deltas can show a false price. Mark the connection for resynchronization, obtain a fresh snapshot, then apply later deltas.
Test the invariants directly: a mismatched tenant, a revoked subscription during fan-out, duplicate event IDs, out-of-order sequences, an expired reconnect token, and one tenant saturating its queue while another remains healthy. The pass condition includes zero cross-tenant writes to the outbound sink and an audit record for every rejection.
I'm not sure a universal audit-retention period exists for marketplaces; legal basis, jurisdiction, and incident-response needs decide it. Keep secrets out of audit records, document the decision, and let the data owner review it. Compliance is part of the boundary, not paperwork after launch.
How do transport choices affect token scope and client trust?
Choose the wire protocol after the scope and replay contracts. A server-to-client event stream suits dashboards that mostly receive updates, with a separate authenticated bid request. A full-duplex connection fits a session that sends commands and receives rapid state changes, but it makes admission, policy refresh, backpressure, and replay gateway responsibilities. Peer data channels are appropriate only when direct peer communication is itself a requirement; they are not a shortcut around a centrally authorized auction ledger.
| Transport | Useful fit | Trade-off |
|---|---|---|
| Server-to-client stream | Display-heavy dashboards | Bid commands need another path and cursor contract |
| Full-duplex connection | Interactive bidding sessions | More connection state and explicit authorization refresh |
| Peer data channel | Genuine peer-to-peer features | Central tenant policy and replay still need a server |
The W3C WebRTC Recommendation defines browser APIs for real-time peer communication, including data channels. It does not decide which marketplace tenant may observe a bid. That decision remains an application responsibility.
Stick with a server stream when the browser only consumes auction changes. Use full duplex when two-way messaging materially simplifies the session. Pick peer communication only after its trust model, revocation path, and reconciliation story are written down. Your mileage may vary with network topology, but no transport supplies tenant isolation for free.
A rollout that keeps the audit trail intact
Start in observation mode: attach authoritative scope to events, evaluate the delivery comparison, and record mismatches without changing the client protocol. Enforce it for an internal auction cohort next, alerting on denied deliveries, replay gaps, queue age, and snapshot fallbacks. Expand by tenant cohort only after committed, published, and acknowledged cursors converge during reconnect and burst tests.
Then require the schema version and cursor behavior at connection admission, revoke subscriptions when policy changes, and keep rollback at the gateway deployment boundary. Do not weaken event scope to rescue an old client.
The catch is that this design is not suitable when your product has no server-owned auction state or when direct peer exchange is the actual feature. In that case, stick with a simpler peer model and accept that tenant policy must be enforced elsewhere. For a centrally adjudicated marketplace, four checks keep one accepted bid inside the auction and tenant that own it.
Top comments (0)