Conditional publish based on user permissions
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:
- Make
Messagean interface so I can attach arbitrary metadata - Add a public
map[string]anyfield toMessage - Pass
context.ContexttoMessageWriter.Sendso I can grab metadata from that - Introduce first-class middleware support
- Tease apart the pub/sub and wire-format responsibilities - looks like this is already on your radar
Cheers!
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:
- sending messages to individual connections only (#36, still have to look into that)
- 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>:vieworposts:<group-id>:create
- for example:
- 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:...:createcreating a post would generate an event for those withposts:...: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:
- "Make Message an interface so I can attach arbitrary metadata", "Add a public
map[string]anyfield to Message", "Passcontext.ContexttoMessageWriter.Sendso 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 customsse.MessageWriterimplementation (maybe a wrapper aroundsse.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
MessageWritershould not cause performance issues – if theSendimplementation usessse.Message.WriteTothe performance won't be harmed. In fact, as of now there isn't any way to write ansse.Messageother than the optimized one. Why do you say you'd have to unmarshal theMessageevery time? Do you require reading the message data for authorization? Wouldn't you be able to use thesse.Message.Eventfield and filter based on that? For example, it could have the formatpost.<group-id>.created, and you could dispatch it to users which have theposts:viewpermission in the respective group.
- also, a custom
- "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 - "Tease apart the pub/sub and wire-format responsibilities"
In essence what I'd like to do is to replace
sse.MessageWriterwith 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.
}
- (my solution) add a
sse.Server.OnMessagecallback, similar to theOnSessionone. The implementation would be very easy with the changes from 3, a bit more involved without (it would imply creating a customMessageWriter)
- this would basically mean that the library is doing the plumbing for yourself, so you don't have to create neither a new
http.Handlernor a newsse.MessageWriter - the wins would be minimal if there is no
sse.MessageWriterthough, as you can see above – if I get rid ofsse.MessageWriterthere wouldn't even be a need of ansse.Server, as making one yourself is trivial. The only thingsse.Servergains the library is easier adoption and better discovery – there's no effort needed to create anhttp.Handlerand without reading the documentation it's much more probable for a new person to findsse.Serverand start using that than to know to create ansse.Joeand anhttp.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 customMessageWriteris 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.Handlerthemselves. Of course, I'm not required to succumb to every request and I'm in a position to limitsse.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 forsse.Serverto 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!