diff --git a/README.md b/README.md index 051aef7..12b73b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugin.go b/plugin.go index 59707d6..b6969eb 100644 --- a/plugin.go +++ b/plugin.go @@ -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. diff --git a/realtime_access.go b/realtime_access.go index c74f8bc..3676773 100644 --- a/realtime_access.go +++ b/realtime_access.go @@ -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) diff --git a/realtime_access_test.go b/realtime_access_test.go index ad3bf2b..efcf8ad 100644 --- a/realtime_access_test.go +++ b/realtime_access_test.go @@ -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] diff --git a/routes.go b/routes.go index 2d378d6..cc35f16 100644 --- a/routes.go +++ b/routes.go @@ -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