go-sse icon indicating copy to clipboard operation
go-sse copied to clipboard

Conditional publish based on user permissions

Open NathanBaulch opened this issue 1 year ago • 1 comments

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!

NathanBaulch avatar Aug 20 '24 05:08 NathanBaulch

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:

  • assumptions:
    • permissions usually imply allowing/denying an operation on a certain entity
    • entities may also be part of a certain scope
    • a scope may be static/known ahead of time for the entire product or dynamically generated
      • for example, on a social media platform there may be multiple groups in which one can post – a post may generate events scoped to that group only, but users should not be able to consume events of the type which they're allowed to consume from groups they're not part of
      • another example would be multi-tenant applications, where the same event types would occur in distinct tenants, but events in one tenant should still be isolated from outside consumption
    • with these in mind, let's invent an example permissions system, where each permission is represented by a string with the following format: <entity>[:<scopes>]:<operation>
      • for example: posts:<group-id>:view or posts:<group-id>:create
  • the idea: do not partition users by the permissions they have (i.e. create topics for each set of existing permissions) but use the individual permissions as topics for the messages
    • if this is what you actually meant in your issue then just skip this section
    • this works in the previously assumed permissions model because a certain operation on an entity would generate events relevant to users allowed to do other operations (for example, someone with posts:...:create creating a post would generate an event for those with posts:...:view)
    • this works when the valid elements of the cross product between the sets of entities, scopes and operations is reasonably small (i.e. what if the user is part of 1 million groups? what about comments:<post-id>:view?)
  • implementation:
    • when publishing a message, set the topics to all the permissions which the target users have
    • when subscribing users, set the topics to all their permissions
    • if this is a multi-tenant app you can scope the events to each tenant by using distinct providers for each tenant (e.g. map[TenantID]*sse.Joe) and a custom HTTP handler to subscribe new users to the correct provider.

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.
}
  1. (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!

tmaxmax avatar Aug 24 '24 12:08 tmaxmax