tmaxmax / go-sse

Fully featured, spec-compliant HTML5 server-sent events library
https://pkg.go.dev/github.com/tmaxmax/go-sse
MIT License
325 stars 17 forks source link

Conditional publish based on user permissions #40

Open NathanBaulch opened 1 month ago

NathanBaulch commented 1 month ago

I'm enjoying this library and impressed by your responsiveness to people's issues!

One problem I'm having is I need to filter events per session based on authorization rules in my security layer. I have a single SSE endpoint that thousands of users connect to, each with unique permissions that influence the events they receive.

I thought about using a custom MessageWriter wrapper which might work, but the concrete Message struct is highly optimized for streaming and I'd rather not have to unmarshal the message data for every connected client on the way out.

I also thought about partitioning my users into separate topics but my permissions are too fine-grained to make this feasible.

Couple of ideas off the top of my head:

  1. Make Message an interface so I can attach arbitrary metadata
  2. Add a public map[string]any field to Message
  3. Pass context.Context to MessageWriter.Send so I can grab metadata from that
  4. Introduce first-class middleware support
  5. Tease apart the pub/sub and wire-format responsibilities - looks like this is already on your radar

Cheers!

tmaxmax commented 1 month ago

Hi Nathan,

Thank you for raising this issue! Apologies for the delayed response – life just seems to happen and hamper my swiftness to reply.

As a side-note, in retrospective it's just funny to me that I didn't think of these use-cases when I've first designed the library:

  1. sending messages to individual connections only (#36, still have to look into that)
  2. filtering which messages each client receives based not just on topic (this issue)

It can only make me happy that there's enough usage for them to have been uncovered and that even without their support the library has still gained some traction.

Coming back on track to the issue, I can imagine how partitioning users by permissions might not work. But some other idea to use topics just came to my mind and maybe could work – let me know if it could apply to your use-case. Here's how it goes:

If your scenario is one where such an implementation would work, you're in the happy case to not have to wait for a library update.

If you're not, let's see what could be done. I'll go through your proposals and also add some of mine:

  1. "Make Message an interface so I can attach arbitrary metadata", "Add a public map[string]any field to Message", "Pass context.Context to MessageWriter.Send so I can grab metadata from that" These are very similar, in fact almost the same. I'd like to understand better what sort of data you would attach? I assume you'd use this additional data in conjunction with a custom sse.MessageWriter implementation (maybe a wrapper around sse.Session?) which contains some extra data about the user, and based on these two data sets (message + user) you'd do the filtering.
    • also, a custom MessageWriter should not cause performance issues – if the Send implementation uses sse.Message.WriteTo the performance won't be harmed. In fact, as of now there isn't any way to write an sse.Message other than the optimized one. Why do you say you'd have to unmarshal the Message every time? Do you require reading the message data for authorization? Wouldn't you be able to use the sse.Message.Event field and filter based on that? For example, it could have the format post.<group-id>.created, and you could dispatch it to users which have the posts:view permission in the respective group.
  2. "Introduce first-class middleware support" Sounds interesting, would you mind elaborating maybe a bit more? Around what would such middlewares be applied? Providers? MessageWriter? Maybe a brief API outline would help so I can visualize exactly what it would entail
  3. "Tease apart the pub/sub and wire-format responsibilities" In essence what I'd like to do is to replace sse.MessageWriter with a <-chan *sse.Message. Then there could be a custom HTTP handler which would look like:

    func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    sess, err := sse.Upgrade(w, r)
    if err != nil { /* handle unsupported client */ }    
    
    user, err := s.authenticate(r)
    if err != nil { /* handle unauthenticated access */ }
    
    msgs := make(chan *sse.Message)
    // we can ignore this error because it will probably be just the context error with this update
    go joe.Subscribe(r.Context(), sse.Subscription{Chan: msgs, LastEventID: sess.LastEventID, Topics: []string{...}})
    
    for msg := range msgs {
        // your own filtering based on permissions
        switch err := s.shouldDispatch(r.Context(), user, msg); err.(type) {
        case auth.ErrUnauthorized:
            continue
        default:
            /* handle auth errors: e.g. database/auth provider access */
            return
        }
    
        if err := sess.Send(msg); err != nil {
            /* handle write error */
            return
        }
    }
    
    // on ServeHTTP return the request context will be cancelled, https://pkg.go.dev/net/http#Request.Context.
    // Joe will respect cancellation and clean up the subscription.
    }
  4. (my solution) add a sse.Server.OnMessage callback, similar to the OnSession one. The implementation would be very easy with the changes from 3, a bit more involved without (it would imply creating a custom MessageWriter)
    • this would basically mean that the library is doing the plumbing for yourself, so you don't have to create neither a new http.Handler nor a new sse.MessageWriter
    • the wins would be minimal if there is no sse.MessageWriter though, as you can see above – if I get rid of sse.MessageWriter there wouldn't even be a need of an sse.Server, as making one yourself is trivial. The only thing sse.Server gains the library is easier adoption and better discovery – there's no effort needed to create an http.Handler and without reading the documentation it's much more probable for a new person to find sse.Server and start using that than to know to create an sse.Joe and an http.Handler. This seems to be a compelling enough argument to ditch minimalism in this case.
    • it seems like a nice solution if I don't get rid of sse.MessageWriter, as creating a custom MessageWriter is complicated enough or implies enough boilerplate for this helper's existence to be justified.
    • there's also a general issue with adding these hooks: I feel like it's bound for a library user to come and request that these hooks support some other feature. Their complexity can unboundedly grow, making them too thin of an abstraction to justify their maintenance in place of just letting the user implement the http.Handler themselves. Of course, I'm not required to succumb to every request and I'm in a position to limit sse.Server's complexity to handle just most use cases, not all – it's just that if for example only 10% of usage is simple enough for sse.Server to support it then again it may not be worth keeping it in the library.

These would be my thoughts. Would be very happy to have some more insight into your issue, as requested in points 1 and 2 above, and your feedback on 3 and 4, and whether any of these solutions would actually help you implement filtering in your application.

Hopefully I've properly understood your problem and gave valuable insight and proposals. Looking forward to your answer!