Files
sm-bm-plugin/realtime_access.go
Jakub Zych 8bef2d7acf feat: material managers get a realtime token
- jz.bm.realtime-access admits holders of jz.bm.access_materials
  (Assumption A5), so every editor can follow materials:{id}
- README documents the materials channel, event and authorizer
2026-11-09 01:14:56 +01:00

126 lines
3.8 KiB
Go

package bm
import (
"net/http"
"sync"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
)
// MiddlewareRealtimeAccess guards the realtime connection-token route: a
// token is minted only for someone with a realtime consumer, a chat-group
// member, a material manager (jz.bm.access_materials, for the live revision
// heads-up of the materials they edit) or a presentation owner. It runs
// after jwt.auth. The token only opens the socket: every channel is still
// authorized by its namespace's authorizer.
const MiddlewareRealtimeAccess = "jz.bm.realtime-access"
// presentationsOwnerGuard is golem15.presentations' owner guard, named
// here without importing that plugin.
const presentationsOwnerGuard = "presentations.realtime-owner"
// Middlewares exports the realtime access guard and the MCP link rewriter.
func (p *Plugin) Middlewares() map[string]pact.Middleware {
return map[string]pact.Middleware{
MiddlewareRealtimeAccess: p.realtimeAccess,
MiddlewareMCPLinks: p.mcpLinks,
}
}
// realtimeAccess lets a chat-group member or a material manager through,
// otherwise, when golem15.presentations is active, whoever its owner guard
// lets through (it answers its own refusals), and refuses everyone else. The owner guard
// is looked up once, by name, among the activated plugins. With BM's own
// Centrifugo instance the tokens it mints are valid for BM only.
func (p *Plugin) realtimeAccess(next http.Handler) http.Handler {
var once sync.Once
var owner http.Handler
ownerGuard := func() http.Handler {
once.Do(func() {
if mw := p.presentationsMiddleware(presentationsOwnerGuard); mw != nil {
owner = mw(next)
}
})
return owner
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := bouncer.User(r.Context())
if !ok || user == nil || user.ID == 0 {
writeRealtimeGuard(w, http.StatusUnauthorized, "Unauthorized")
return
}
db := p.database()
if db == nil {
writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable")
return
}
if p.app.HasPlugin("golem15.chat") {
isMember := p.chatMember
if isMember == nil {
isMember = classes.IsChatMember
}
member, err := isMember(r.Context(), db, user.ID, p.chatGroup())
if err != nil {
writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable")
return
}
if member {
next.ServeHTTP(w, r)
return
}
}
isManager := p.materialManager
if isManager == nil {
isManager = api.ManagesMaterials
}
manager, err := isManager(r.Context(), db, user.ID)
if err != nil {
writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable")
return
}
if manager {
next.ServeHTTP(w, r)
return
}
if p.app.HasPlugin("golem15.presentations") {
if h := ownerGuard(); h != nil {
h.ServeHTTP(w, r)
return
}
}
writeRealtimeGuard(w, http.StatusForbidden, "Forbidden")
})
}
// presentationsMiddleware returns the named middleware of the activated
// golem15.presentations plugin, or nil.
func (p *Plugin) presentationsMiddleware(name string) pact.Middleware {
if p.app == nil {
return nil
}
activated, ok := p.app.Lookup[party.Activated]()
if !ok {
return nil
}
for _, pl := range activated {
if pl.ID() != "golem15.presentations" {
continue
}
if hm, ok := pl.(pact.HasMiddleware); ok {
return hm.Middlewares()[name]
}
}
return nil
}
// writeRealtimeGuard answers in the token route's own error shape.
func writeRealtimeGuard(w http.ResponseWriter, status int, message string) {
w.Header().Set("Cache-Control", "no-cache, private")
wire.WriteJSON(w, status, map[string]string{"error": message})
}