feat: SSE event permission filtering

Filter SSE events by the connected user's collection visibility so
events about items in hidden collections are never sent (TASK-487).

- Compute visible collection slug set at SSE connection time
- Filter live events: check event.Collection against visible set
- Filter replayed events: same check on missed event replay
- Events without a collection field always pass through
- Admins and "all access" members see everything (nil set = no filter)
This commit is contained in:
xarmian
2026-04-11 01:23:36 +00:00
parent 0fb8042ad2
commit 1ee69df394
+35 -4
View File
@@ -75,6 +75,33 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
}
}
// Compute the user's visible collection set for event filtering.
// Build a slug-based set since events carry collection slugs, not IDs.
var visibleSlugSet map[string]bool // nil = all access (no filtering)
visibleIDs, err := s.visibleCollectionIDs(r, ws.ID)
if err != nil {
slog.Warn("SSE: failed to resolve visible collections, allowing all", "error", err)
} else if visibleIDs != nil {
visibleSlugSet = make(map[string]bool, len(visibleIDs))
for _, id := range visibleIDs {
coll, _ := s.store.GetCollection(id)
if coll != nil {
visibleSlugSet[coll.Slug] = true
}
}
}
// sseEventVisible checks if an event should be sent to this client.
sseEventVisible := func(collection string) bool {
if visibleSlugSet == nil {
return true // all access
}
if collection == "" {
return true // events without a collection are always sent
}
return visibleSlugSet[collection]
}
// Send initial connected event
writeSSEEvent(w, "connected", 0, map[string]string{
"workspace_id": ws.ID,
@@ -100,8 +127,10 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
slog.Info("SSE replaying missed events",
"workspace", ws.Slug, "last_event_id", lastID, "count", len(missed))
for _, event := range missed {
writeSSEEvent(w, event.Type, event.ID, event)
flusher.Flush()
if sseEventVisible(event.Collection) {
writeSSEEvent(w, event.Type, event.ID, event)
flusher.Flush()
}
}
}
// If len(missed) == 0: client is caught up, nothing to replay.
@@ -124,8 +153,10 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
// Channel closed (unsubscribed)
return
}
writeSSEEvent(w, event.Type, event.ID, event)
flusher.Flush()
if sseEventVisible(event.Collection) {
writeSSEEvent(w, event.Type, event.ID, event)
flusher.Flush()
}
case <-keepalive.C:
// Send keepalive comment to prevent proxy/LB timeouts