Files
team/server/channels/app/channel_guards.go
ce5644223a [MM-68400] Four plugin hooks and ChannelGuard enforcement (#36152)
* allow workflow_dispatch trigger for Server CI (for plugins CI)

* [MM-68402] MBE Phase 2: declare four generic plugin hooks (#36291)

* new hooks-only phase 2

* remove ChannelWillBeMoved

* remove RecapWillBeProcessed and MessageWillBeRewrittenByAI

Drop the AI/recap hooks from the new-hook surface; AI-LLM paths
remain uncovered in tech preview and are documented as residuals.

* [MM-68403] MBE Phase 3: ChannelGuards primitive (storage + cache + plugin API) (#36365)

* phase 3

* phase 3: register ChannelGuard mock in test setup helper

NewChannels' startup-time call to reloadGuardCache invokes
s.ChannelGuard().GetAll(); without an expectation on the mock store,
every test that sets up the server with GetMockStoreForSetupFunctions
panics during init.

* phase 3: register ChannelGuard mock in retrylayer test

retrylayer.New walks every store getter to wrap it; without the mock
expectation on ChannelGuard, TestRetry panics during layer construction.

* use rctx properly in the store methods

* phase 3: match rctx arg in testlib ChannelGuard mock

GetAll now takes request.CTX, so the testify expectation must include
mock.Anything; otherwise the call panics under the mocked store.

* phase 3: set api.ctx in TestChannelGuardLowercaseNormalization

The test constructs PluginAPI directly without a ctx, which used to
work when App.RegisterChannelGuard built its own EmptyContext. Now
that the App methods take rctx from the caller, the nil ctx panics
inside RequestContextWithMaster.

* [MM-68404] MBE Phase 4: App-layer plugin hook wiring (#36407)

* phase 4

* Fix nil rctx in TestChannelGuardLowercaseNormalization

The PluginAPI struct literal was missing ctx: rctx after a refactor
moved the rctx declaration below the struct construction, leaving
api.ctx as nil. This caused a nil pointer dereference in reloadGuardCache
when RegisterChannelGuard called store.RequestContextWithMaster(nil).

* Remove ChannelWillBeMoved hook call from MoveChannel (phase 4)

The hook and its ID were removed from mbe-phase-2 but the call site in
MoveChannel and its i18n string were not cleaned up during the rebase.

* remove channel will be moved test

* Remove RecapWillBeProcessed and MessageWillBeRewrittenByAI hook calls (phase 4)

The hooks and their IDs were removed from mbe-phase-2 but the call sites
in ProcessRecapChannel and RewriteMessage, their i18n strings, and their
tests were not cleaned up during the rebase.

* Revert channel_id plumbing on rewrite endpoint (phase 4)

The channel_id field on RewriteRequest was added in phase 4 to feed the
synthetic post passed to MessageWillBeRewrittenByAI. With that hook
removed from mbe-phase-2, channel_id has no consumer; revert the field,
the api4 validation, the app.RewriteMessage parameter, and the
corresponding webapp client + hook plumbing.

* [MM-68555] MBE Phase 5: Channel-guard enforcement + two-phase dispatch (#36473)

* phase 5

* Bake plugin counter-file paths into source instead of env vars

t.Setenv panics when an ancestor test calls t.Parallel, so the two
channel-guard tests broke under ENABLE_FULLY_PARALLEL_TESTS in CI.
Build each plugin source per-subtest with its temp file path embedded
as a Go literal — same pattern as TestPluginUploadsAPI.

* Remove guarded helpers and tests for dropped hooks (phase 5)

The runGuardedRecapWillBeProcessed and runGuardedMessageWillBeRewrittenByAI
helpers were never wired (their app-layer call sites were already removed
in the phase-4 cleanup), and the corresponding sub-tests across panic /
allow / reject / partial plugins reference hooks that no longer exist.

* [MM-68405] MBE Phase 6: fire MessagesWillBeConsumed on the edit path (#36475)

---------

---------

---------

---------

* rebase onto master

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-21 18:16:05 +00:00

217 lines
7.3 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// Backoff bounds for the guard-cache reload retry. Package vars (not consts) so tests can shrink
// them via t.Cleanup-restored override.
var (
guardCacheRetryInitialDelay = 1 * time.Second
guardCacheRetryMaxDelay = 5 * time.Minute
)
const clusterEventInvalidateChannelGuardCache = model.ClusterEvent("inv_channel_guards")
// reloadGuardCache scans the ChannelGuards table and atomically replaces the in-memory cache with
// the result. Used both at startup (from NewChannels) and from the cluster invalidation handler.
// Forces a master read because all callers (post-write reload, cluster invalidation) can race with
// replica lag.
func (ch *Channels) reloadGuardCache(rctx request.CTX, s store.Store) error {
guards, err := s.ChannelGuard().GetAll(store.RequestContextWithMaster(rctx))
if err != nil {
return err
}
fresh := &sync.Map{}
grouped := map[string][]*store.ChannelGuard{}
for _, g := range guards {
grouped[g.ChannelId] = append(grouped[g.ChannelId], g)
}
for channelID, slice := range grouped {
fresh.Store(channelID, slice)
}
ch.guardCache.Store(fresh)
return nil
}
// getGuardsForChannel returns the cached guard slice for a channel, or nil if none.
func (ch *Channels) getGuardsForChannel(channelID string) []*store.ChannelGuard {
m := ch.guardCache.Load()
if m == nil {
return nil
}
v, ok := m.Load(channelID)
if !ok {
return nil
}
guards, _ := v.([]*store.ChannelGuard)
return guards
}
// clusterInvalidateGuardCacheHandler is registered as the receive-side handler for
// clusterEventInvalidateChannelGuardCache. The handler refetches the entire table.
func (ch *Channels) clusterInvalidateGuardCacheHandler(msg *model.ClusterMessage) {
rctx := request.EmptyContext(ch.srv.Log())
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
ch.srv.Log().Warn(
"Failed to reload channel guard cache after cluster invalidation; retry scheduled",
mlog.String("event", string(msg.Event)),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
}
// broadcastChannelGuardInvalidation tells the rest of the cluster to refetch their guard caches.
// The payload is intentionally empty.
func (ch *Channels) broadcastChannelGuardInvalidation() {
cluster := ch.srv.platform.Cluster()
if cluster == nil {
return
}
msg := &model.ClusterMessage{
Event: clusterEventInvalidateChannelGuardCache,
SendType: model.ClusterSendReliable,
WaitForAllToSend: true,
}
cluster.SendClusterMessage(msg)
}
// RegisterChannelGuard records that pluginID claims channelID. The caller's pluginID is expected to
// be lowercased.
func (a *App) RegisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
if channelID == "" {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.empty_channel.app_error", nil, "", http.StatusBadRequest)
}
if !model.IsValidId(channelID) {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
}
guard := &store.ChannelGuard{
ChannelId: channelID,
PluginId: pluginID,
CreatedAt: model.GetMillis(),
}
if err := a.Srv().Store().ChannelGuard().Save(rctx, guard); err != nil {
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
ch := a.Channels()
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
a.Srv().Log().Warn(
"Failed to reload channel guard cache after Register; retry scheduled",
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
ch.broadcastChannelGuardInvalidation()
return nil
}
// UnregisterChannelGuard removes pluginID's claim on channelID. If pluginID has no claim on the
// channel, this is a no-op (returns nil). The store-level DELETE matches by both ChannelId and
// PluginId, so other plugins' claims on the same channel are left untouched.
func (a *App) UnregisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
if channelID == "" {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.empty_channel.app_error", nil, "", http.StatusBadRequest)
}
if !model.IsValidId(channelID) {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
}
rowsAffected, err := a.Srv().Store().ChannelGuard().Delete(rctx, channelID, pluginID)
if err != nil {
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
if rowsAffected == 0 {
a.Srv().Log().Warn(
"UnregisterChannelGuard removed no rows; pluginID does not match any guard for this channel",
mlog.String("error_id", "unregister_no_matching_guard"),
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
)
}
ch := a.Channels()
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
a.Srv().Log().Warn(
"Failed to reload channel guard cache after Unregister; retry scheduled",
mlog.String("channel_id", channelID),
mlog.String("plugin_id", pluginID),
mlog.Err(err),
)
ch.scheduleGuardCacheReloadRetry()
}
ch.broadcastChannelGuardInvalidation()
return nil
}
// scheduleGuardCacheReloadRetry kicks off a single in-flight retry goroutine that calls
// reloadGuardCache with exponential backoff until success or until the server is shutting down.
// Multiple concurrent calls collapse to a single retry — useful when Register, Unregister, the
// cluster handler, and the startup loader can all see the same DB outage simultaneously.
//
// Returns true if a new retry goroutine was scheduled, false if one was already in flight. Call
// sites can ignore the return value; tests use it to assert single-flight semantics.
func (ch *Channels) scheduleGuardCacheReloadRetry() bool {
if !ch.guardCacheRetryInFlight.CompareAndSwap(false, true) {
return false
}
go ch.runGuardCacheReloadRetry()
return true
}
func (ch *Channels) runGuardCacheReloadRetry() {
defer ch.guardCacheRetryInFlight.Store(false)
rctx := request.EmptyContext(ch.srv.Log())
delay := guardCacheRetryInitialDelay
for attempt := 1; ; attempt++ {
timer := time.NewTimer(delay)
select {
case <-ch.interruptQuitChan:
timer.Stop()
ch.srv.Log().Info(
"Channel guard cache reload retry cancelled by shutdown",
mlog.Int("attempt", attempt),
)
return
case <-timer.C:
}
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
ch.srv.Log().Info(
"Channel guard cache reload retry attempt failed; will retry",
mlog.Int("attempt", attempt),
mlog.Err(err),
)
delay *= 2
if delay > guardCacheRetryMaxDelay {
delay = guardCacheRetryMaxDelay
}
continue
}
ch.srv.Log().Info(
"Channel guard cache reload retry succeeded",
mlog.Int("attempt", attempt),
)
return
}
}