Skip to content

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-eventstream 5.3.3 as installed, read under .venv/lib/python3.12/site-packages/django_eventstream/. Version from uv.lock (name = "django-eventstream" / version = "5.3.3"); pyproject.toml pins only django-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 in django_eventstream_event and django_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:

  1. The stream endpoints are unauthenticated. MIDDLEWARE has no LoginRequiredMiddleware and the mounted view has no decorator [S3], so an anonymous request to /items/stream/ opens a stream today. It leaks only event: update with an empty payload, so nothing is disclosed — but the endpoint is not behind login, and a Circle boundary cannot assume it is.
  2. 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-get that follows is guarded separately by whatever scoping items/views.py gains. 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
EVENTSTREAM_CHANNELMANAGER_CLASS = "myapp.channelmanager.MyChannelManager"

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_request raises EventRequest.Error("No channels specified") when fewer than one channel comes back [S1 eventrequest.py:69], which the view turns into an SSE stream-error with condition bad-request. A circle-less or anonymous user therefore gets an error stream, not silence. That is a rendering decision the map has to make, because partner_of returning None is already a live branch on every E2E run (#18).
  • The call is sync and may hit the ORM safely. events() is a plain def [S1 views.py:286], so under ASGI Django runs it in a threadpool; a DB lookup inside get_channels_for_request is fine. Only the streaming generator is async, and it wraps get_events in sync_to_async [S1 views.py:155].
  • Ten channels per connection, by default. EventRequest.__init__ takes channel_limit=10 and events() never overrides it [S1 eventrequest.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.channel and EventCounter.name [S1 models.py]. circle-<pk> or circle-<uuid> is far inside it.
  • Characters: no validation anywhere. The name is URL-quoted where it matters — make_id quotes it into the Last-Event-ID and parse_last_event_id unquotes it [S1 utils.py:29-36, utils.py:20-26], and publish_event quotes it into the GRIP channel "events-%s" [S1 utils.py:100]. Since quote() escapes : and , — the two characters parse_last_event_id splits on — those are survivable. / is not escaped by quote()'s default safe="/", 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_request deriving the name server-side, a sequential circle-<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-channels uses str.format, so a channel template may only interpolate view kwargs [S1 channelmanager.py:17-21] — it cannot reach request.user. Deriving a channel from the logged-in user requires overriding get_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:

  1. A CircleChannelManager overriding both get_channels_for_request (from request.user, never from URL or query string) and can_read_channel (handling user is None), wired via EVENTSTREAM_CHANNELMANAGER_CLASS.
  2. _broadcast() in items/services.py stops 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.
  3. A decision on what a circle-less or anonymous request to /items/stream/ should get, since returning no channels produces a bad-request error stream. Login-gating the two stream URLs is the obvious first move and is missing today.
  4. 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.
  5. A stream-error (and probably stream-reset) listener in frontend/main.js. Otherwise every failure in 1–4 presents as "the lists just stopped updating".
  6. Optionally, deleting the EventCounter row 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.