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
This commit is contained in:
Jakub Zych
2026-11-09 01:14:56 +01:00
parent 2a47186a7a
commit 8bef2d7acf
5 changed files with 51 additions and 9 deletions

View File

@@ -441,6 +441,12 @@ Attached PDFs are searchable too. After any attach (the admin form, `POST /api/v
Configuration, in the app's `config/search.yaml`: `search.driver`, `search.prefix`, `search.typesense.host`, `port`, `protocol` and `connection_timeout_seconds`. The API key goes only in the environment, `SUMMER_SEARCH__TYPESENSE__API_KEY`; with no key the engine is not configured, nothing is sent and every search answers 503.
## Realtime
An open editor learns of a newer revision before its own save (D-20); the save-time 409 stays the guarantee. After every write that bumps a material's revision (the author's `PUT /_bm/api/v1/materials/{id}`, the management `PUT` and `POST` upsert, so also `bm-notatka push` and the MCP tools, and a `/bmadmin` save) the server enqueues, on the write's own transaction, one broadcast on channel `materials:{id}` with event `updated.jz.bm.material` and payload `{"material_id": 5, "revision": 4}`. It is published only if the write commits, and only ids and the revision number travel, never content. The enqueue runs in a savepoint: a failure is logged and never fails or loses the save. A write that does not bump (visibility, shares, unpublish, files, identical content) publishes nothing (`classes.MaterialRevisionNotifier`).
jz.bm owns the realtime namespace `materials`. Its authorizer accepts only channels of the form `materials:{id}` (`^materials:[1-9][0-9]{0,18}$`; a `presence:` prefix, a leading zero or trailing characters are denied) and allows a subscribe only when the material is visible to the user and they may manage it: `jz.bm.access_materials` and its author, or any such user while it has no author (`api.CanWatchMaterial`), checked on current state at every subscribe. The connection-token guard `jz.bm.realtime-access` admits material managers besides chat-group members and presentation owners, so every possible editor can get a token; the token and subscribe-proxy routes are mounted only while `golem15.chat` or `golem15.presentations` is active. The Centrifugo instance needs the `materials` namespace (see the app's `DEPLOY.md`).
## CLI commands
### bm:materials-reindex

View File

@@ -71,7 +71,9 @@ type Plugin struct {
// their own.
watchMaterial func(ctx context.Context, db *gorm.DB, userID, id uint) (bool, error)
// materialManager is the material-manager check of the realtime access
// guard; nil means api.ManagesMaterials. Tests inject their own.
// guard (Assumption A5: every possible editor gets a connection token
// for the materials heads-up); nil means api.ManagesMaterials. Tests
// inject their own.
materialManager func(ctx context.Context, db *gorm.DB, userID uint) (bool, error)
// materialIndex searches the materials index and keeps it current. Nil
// when Boot did not run: then search answers 503 and syncs are skipped.

View File

@@ -9,11 +9,15 @@ import (
"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 or a presentation owner. It runs after jwt.auth.
// 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
@@ -28,9 +32,9 @@ func (p *Plugin) Middlewares() map[string]pact.Middleware {
}
}
// realtimeAccess lets a chat-group member 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
// 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 {
@@ -70,6 +74,19 @@ func (p *Plugin) realtimeAccess(next http.Handler) http.Handler {
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)

View File

@@ -48,6 +48,15 @@ func chatGroupMember(_ context.Context, _ *gorm.DB, userID uint, group string) (
return group == "studenci" && userID == 7, nil
}
// materialManager is the injected material-manager check: user 9 holds
// jz.bm.access_materials, user 14 makes the check fail.
func materialManager(_ context.Context, _ *gorm.DB, userID uint) (bool, error) {
if userID == 14 {
return false, errors.New("database down")
}
return userID == 9, nil
}
// accessPlugin builds jz.bm on an app with the given active plugin ids, a
// published (fake) database unless noDB, and golem15.presentations in the
// activated list when withOwnerGuard.
@@ -71,7 +80,7 @@ func accessPlugin(t *testing.T, ids []string, noDB, withOwnerGuard bool) *Plugin
if err := app.Publish(activated); err != nil {
t.Fatal(err)
}
return &Plugin{app: app, chatMember: chatGroupMember}
return &Plugin{app: app, chatMember: chatGroupMember, materialManager: materialManager}
}
func TestRealtimeAccess(t *testing.T) {
@@ -96,6 +105,10 @@ func TestRealtimeAccess(t *testing.T) {
{"no database", both, true, true, 7, http.StatusServiceUnavailable},
{"membership check fails", both, false, true, 13, http.StatusServiceUnavailable},
{"no consumer", []string{"golem15.user", "jz.bm"}, false, false, 7, http.StatusForbidden},
{"material manager", both, false, true, 9, http.StatusOK},
{"material manager without chat", presOnly, false, true, 9, http.StatusOK},
{"material manager without presentations", chatOnly, false, false, 9, http.StatusOK},
{"manager check fails", both, false, true, 14, http.StatusServiceUnavailable},
} {
p := accessPlugin(t, c.ids, c.noDB, c.guard)
mw, ok := p.Middlewares()[MiddlewareRealtimeAccess]

View File

@@ -313,9 +313,13 @@ func (p *Plugin) Routes(r pact.Router) error {
// (server-to-server, authenticated by its shared secret). The realtime
// consumers are the BM chat (golem15.chat) and the phone remote of
// golem15.presentations, so nothing is mounted without either, and the
// token route is limited to chat-group members and presentation owners by
// jz.bm.realtime-access, which reaches the presentations guard by name
// without importing the plugin. Only the token route is throttled, per
// token route is limited to chat-group members, material managers and
// presentation owners by jz.bm.realtime-access, which reaches the
// presentations guard by name without importing the plugin. The materials
// heads-up (materials:{id}) rides on the same token and proxy routes, so
// it is live whenever golem15.chat or golem15.presentations is active, as
// on rome; without either the editor shows no live banner and the
// save-time 409 still protects it. Only the token route is throttled, per
// user. The subscribe proxy is called by Centrifugo from 127.0.0.1 for
// every client, so any throttle there would be one shared bucket that a
// single token holder could exhaust and lock everyone out; it relies on its