What django-eventstream offers for per-channel authorization¶
A channel per Circle is supported, cheap, and has one purpose-built hook — but the hook is opt-in, fails open, is consulted only at connect time, and the denial is silent on the client. Both halves of the job (choosing the channel, guarding it) live in one class, the channel manager, and both must be overridden: overriding only the first gives a boundary any client can walk around.
Issue #19, child of #18. Date: 2026-09-22.
Sources (all primary)¶
- [S1]
django-eventstream5.3.3 as installed, read under.venv/lib/python3.12/site-packages/django_eventstream/. Version fromuv.lock(name = "django-eventstream"/version = "5.3.3");pyproject.tomlpins onlydjango-eventstream>=5.0, so 5.3.3 is what the lock resolves, not what the spec requires. - [S2] The library's own
README.md, from the 5.3.3 sdist (~/.cache/uv/sdists-v6/pypi/django-eventstream/5.3.3/.../src/README.md) — the same text that is the project's documentation. - [S3] This repo:
shared_calendar/urls.py,shared_calendar/asgi.py,shared_calendar/settings.py,shared_calendar/items/services.py. - [S4] The live dev database,
db.sqlite3, read directly — row counts indjango_eventstream_eventanddjango_eventstream_eventcounter.
File and line references below are to the installed 5.3.3 package, not to GitHub main.
Where we stand today¶
shared_calendar/urls.py mounts django_eventstream.urls at two paths, each with a
hardcoded {"channels": ["items"]} [S3]. asgi.py is a plain get_asgi_application(), so
the stream runs as an ordinary sync Django view returning a StreamingHttpResponse —
there is no Channels routing and no AuthMiddlewareStack in play. settings.py sets
EVENTSTREAM_STORAGE_CLASS = "django_eventstream.storage.DjangoModelStorage" [S3], and
items/services.py::_broadcast() calls send_event(SSE_CHANNEL, "update", {}) [S3].
Two things about the present wiring matter for the Circle decision and are easy to miss:
- The stream endpoints are unauthenticated.
MIDDLEWAREhas noLoginRequiredMiddlewareand the mounted view has no decorator [S3], so an anonymous request to/items/stream/opens a stream today. It leaks onlyevent: updatewith an empty payload, so nothing is disclosed — but the endpoint is not behind login, and a Circle boundary cannot assume it is. - The payload carries nothing. Because the event is a contentless "go re-fetch", the
per-Circle channel is about who gets woken, not about what they can read — the
hx-getthat follows is guarded separately by whatever scopingitems/views.pygains. This lowers the blast radius of getting the channel guard wrong, but it does not remove the need for it: subscribing to another Circle's channel is a side-channel that reveals that Circle's activity timing.
The hook: ChannelManagerBase¶
One class, loaded from one setting, answers three questions [S1
channelmanager.py, utils.py:159-163]:
class ChannelManagerBase(object):
def get_channels_for_request(self, request, view_kwargs): ...
def can_read_channel(self, user, channel): ... # user=None for anonymous
def is_channel_reliable(self, channel): ... # only meaningful if storage is enabled
The default is django_eventstream.channelmanager.DefaultChannelManager, whose
can_read_channel is literally return True [S1 channelmanager.py:29-30]. The
authorization hook fails open. That is why the second override is not optional.
The instance is cached in thread-local storage by class path (utils.py:get_class), so it
is constructed once per thread — it must be stateless, and it must not cache per-user
answers on self.
Can channels be derived per-request from the logged-in user?¶
Yes, and it is the documented route. get_channels_for_request receives the full
HttpRequest, so request.user is available; the README ends its routing section with
"If even more advanced channel mapping is needed, implement a channel manager and override
get_channels_for_request" [S2 README:309].
The default implementation is a four-way fallback [S1 channelmanager.py:15-27]:
| view kwarg | behaviour |
|---|---|
format-channels |
each entry .format(**view_kwargs) — channel from the URL path |
channels |
a fixed list (what we use today) |
channel |
a single fixed channel |
| (none) | request.GET.getlist("channel") — the client names its own channels |
That last row is the trap. If a future refactor drops the {"channels": [...]} kwarg from
urls.py without installing a channel manager, every browser gets to pick any channel name
it likes. The README states it plainly: "if no keyword arguments are specified, then the
client can select the channels on its own" [S2 README:288].
For a Circle the natural implementation ignores the URL entirely:
class CircleChannelManager(DefaultChannelManager):
def get_channels_for_request(self, request, view_kwargs):
circle_id = circle_id_for(request.user) # None if anonymous / circle-less
return {f"circle-{circle_id}"} if circle_id else set()
Two mechanics to know about that:
- An empty set is an error, not an empty stream.
EventRequest.apply_http_requestraisesEventRequest.Error("No channels specified")when fewer than one channel comes back [S1eventrequest.py:69], which the view turns into an SSEstream-errorwith conditionbad-request. A circle-less or anonymous user therefore gets an error stream, not silence. That is a rendering decision the map has to make, becausepartner_ofreturningNoneis already a live branch on every E2E run (#18). - The call is sync and may hit the ORM safely.
events()is a plaindef[S1views.py:286], so under ASGI Django runs it in a threadpool; a DB lookup insideget_channels_for_requestis fine. Only the streaming generator is async, and it wrapsget_eventsinsync_to_async[S1views.py:155]. - Ten channels per connection, by default.
EventRequest.__init__takeschannel_limit=10andevents()never overrides it [S1eventrequest.py:24,views.py:292]. One channel per Circle is nowhere near it; it would only bite a design where a person joins many Circles at once — explicitly out of scope for #18.
Can a client subscribe to another Circle's channel by guessing the name?¶
Only if you let it. With get_channels_for_request deriving the channel from
request.user and never from the URL or query string, the name is never client-supplied
and guessing has nothing to act on. can_read_channel is then a second, independent
belt — and it is worth wearing, because it is the only guard that survives someone later
adding a path("events/<channel>/", ...) route.
Where the check actually runs [S1 eventstream.py:105-115]:
for channel in request.channels:
if not channelmanager.can_read_channel(user, channel):
inaccessible_channels.append(channel)
if len(inaccessible_channels) > 0:
raise EventPermissionError(msg, channels=inaccessible_channels)
user is request.user from the EventRequest, which is http_request.user only when
authenticated and None otherwise [S1 eventrequest.py:55-58]. Any implementation must
handle user is None explicitly; user.circle on None is an AttributeError inside a
streaming generator.
Three consequences worth carrying into the design:
(a) The check is a connect-time check, not a continuous one. get_events — and so
can_read_channel — runs at the top of the outer loop in stream(). Once the client is
caught up, the code enters an inner while True that delivers queued events straight from
the listener's in-memory queue and never calls get_events again, breaking out only on
overflow or an injected error [S1 views.py:224-278]. Removing someone from a Circle does
not close their open stream. The library's answer is an explicit eviction call [S2
README:276-282, S1 eventstream.py:163-175]:
from django_eventstream import channel_permission_changed
channel_permission_changed(user, "circle-7")
which re-asks can_read_channel and, on a False, kicks that user's local listeners. Any
future "leave a Circle" / "remove the other person" operation in items/services.py (or
wherever membership lands) owes this call — it belongs next to _broadcast(), in the
service layer, for the same reason _broadcast() does.
(b) A denial is an SSE event, not an HTTP status. In the non-GRIP path — ours, since
there is no Pushpin in front — the first get_events happens inside the async generator,
so the response has already been committed as 200 text/event-stream. The permission error
is written into the body as event: stream-error with {"condition": "forbidden", ...} and
the generator breaks [S1 views.py:165-173]. The events() view's own
except EventPermissionError branch only fires on the GRIP path [S1 views.py:305, and
the GRIP-only branch at views.py:308-313]. So a test asserting 403 will fail; the assertion has to read the stream body.
(c) Nothing on our client handles stream-error. Neither htmx.org/dist/ext/hx-sse.js
nor frontend/main.js mentions stream-error, stream-reset or stream-open [S3, and a
grep of node_modules/htmx.org/dist/ext/hx-sse.js returns nothing]. A forbidden stream
therefore looks, to the user, exactly like a stream that went quiet: the page keeps
rendering and the lists stop moving. Same silent failure mode as the hx-sse:connect
deadlock and the pauseOnBackground reconnect documented in
docs/research/htmx4-sse-timeout.md. If the boundary ships, it needs a stream-error
listener whatever else it gets.
What a channel per Circle costs in storage¶
EVENTSTREAM_STORAGE_CLASS = "django_eventstream.storage.DjangoModelStorage" is on [S3].
Storage is per-channel in two tables [S1 models.py]:
Event—channel(indexed,max_length=255),type,data,eid,created;unique_together = ("channel", "eid").EventCounter—name(unique,max_length=255),value,updated. One row per channel that has ever been written to.
Writing an event to a brand-new channel does three inserts, not one: EventCounter.save()
via get_or_create, then — because counter.value == 0 — a zero-placeholder Event
"to enable querying from ID 0", then the real Event [S1 models.py:37-58]. Each write
takes a select_for_update() on that channel's counter row inside a transaction, so
per-Circle channels also reduce write contention versus one global counter row that every
Circle serialises on. On SQLite, where writes serialise at the database anyway, that is a
wash today; it stops being a wash if the deployment ever moves off SQLite.
Does an unbounded number of channels accumulate rows? Partly — and the unbounded part is the small one.
trim_event_log() runs on every append_event and deletes Event rows older than
EVENT_TIMEOUT = 60 * 24 minutes, 50 at a time [S1 storage.py:17-20, 245-259] — matching the README's "they persist for 24 hours" [S2 README:228]. It filters on
created alone, with no channel filter, so one active Circle's writes garbage-collect
every other Circle's stale events. No cron, no management command (the package ships only
runserver_ngrok [S1 management/commands/]); Event growth is bounded by traffic in the
last 24 hours, not by channel count.
EventCounter rows are never deleted — nothing in the package removes them. So the
permanent cost of a Circle channel is exactly one EventCounter row, forever: name,
a bigint, a timestamp. A Circle that goes dormant leaves one row behind; a Circle that is
deleted leaves one row behind. At the scale #18 contemplates this is noise, and any cleanup
is a one-line delete() we would write ourselves when a Circle is deleted.
The dev database demonstrates both halves at once [S4]:
django_eventstream_event 4 rows
django_eventstream_eventcounter 3 rows
('events', 14, '2026-07-11 ...') <- channel no longer used by any code
('wishlist', 10, '2026-07-11 ...') <- channel no longer used by any code
('items', 73, '2026-09-22 ...')
Two channels from an earlier wiring, dead for two months, still hold counter rows, while
almost every Event row they ever produced has been trimmed. That is the per-Circle
steady state in miniature.
One behavioural consequence of the 24-hour trim, worth stating because per-Circle channels
make it common rather than rare: after a quiet day the zero-placeholder is trimmed too, so a
client reconnecting with a Last-Event-ID gets EventDoesNotExist → stream-reset [S1
storage.py:188-225, views.py:186-192]. A quiet Circle is exactly the case that hits
this. It is benign — the client is meant to re-initialise — but nothing in frontend/
listens for stream-reset either, and our recovery is a full hx-get anyway.
is_channel_reliable(channel) is the third override: returning False for a channel skips
storage entirely, both on write [S1 eventstream.py:55] and on read [S1
eventstream.py:123]. Given our events are contentless refresh pings, turning storage off
for Circle channels is a defensible option that reduces the per-Circle cost to zero rows —
at the price of losing the reconnect repair the README advertises. Not a decision this
ticket needs to make, but it is on the table and it is one method.
Constraints on channel naming¶
- Length: 255 characters, from both
Event.channelandEventCounter.name[S1models.py].circle-<pk>orcircle-<uuid>is far inside it. - Characters: no validation anywhere. The name is URL-quoted where it matters —
make_idquotes it into theLast-Event-IDandparse_last_event_idunquotes it [S1utils.py:29-36,utils.py:20-26], andpublish_eventquotes it into the GRIP channel"events-%s"[S1utils.py:100]. Sincequote()escapes:and,— the two charactersparse_last_event_idsplits on — those are survivable./is not escaped byquote()'s defaultsafe="/", but it is also not a delimiter here, so it passes. - The real constraint is guessability, and it is not enforced by the library. With
get_channels_for_requestderiving the name server-side, a sequentialcircle-<pk>is fine. If any route ever lets a client name a channel, a sequential id becomes an enumeration; an opaque id would be the cheaper insurance. format-channelsusesstr.format, so a channel template may only interpolate view kwargs [S1channelmanager.py:17-21] — it cannot reachrequest.user. Deriving a channel from the logged-in user requires overridingget_channels_for_request; the URL-formatting shortcut cannot do it.
What this means for the map¶
A per-Circle channel is the right shape and is cheap. The work it implies, concretely:
- A
CircleChannelManageroverriding bothget_channels_for_request(fromrequest.user, never from URL or query string) andcan_read_channel(handlinguser is None), wired viaEVENTSTREAM_CHANNELMANAGER_CLASS. _broadcast()initems/services.pystops being a no-argument call — it needs the Circle whose channel to write to, which means every service operation must be able to name one.- A decision on what a circle-less or anonymous request to
/items/stream/should get, since returning no channels produces abad-requesterror stream. Login-gating the two stream URLs is the obvious first move and is missing today. - A
channel_permission_changed(user, channel)call wherever membership ends — without it, a removed member's open stream keeps receiving that Circle's pings until they reload. - A
stream-error(and probablystream-reset) listener infrontend/main.js. Otherwise every failure in 1–4 presents as "the lists just stopped updating". - Optionally, deleting the
EventCounterrow when a Circle is deleted. Nothing else accumulates.
Nothing here requires django-channels, a GRIP proxy, or Redis. The plain-ASGI wiring in
asgi.py stays as it is.