[MM-68649] Add Session Attributes from user agent for use in Permission Policies (#36511)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / Check go fix (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check default roles permissions (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Elasticsearch v8 Compatibility (push) Blocked by required conditions
Server CI / OpenSearch v2 Compatibility (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Tools CI / check-style (mattermost-govet) (push) Waiting to run
Tools CI / Test (mattermost-govet) (push) Waiting to run
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
YAML Lint / yamllint (push) Waiting to run

* [MM-68649] Add Session Attributes from user agent for use in Permission Policies

* Update server/channels/app/session_attributes.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix test

* fix i18n

* Allow session attributes for permission policies when no user attributes are configured

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: maria.nunez <maria.nunez@mattermost.com>
This commit is contained in:
Devin Binnie 2026-05-26 11:24:03 -04:00 committed by GitHub
parent 16c8f9d6e3
commit d9c1388461
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 907 additions and 75 deletions

View file

@ -16,6 +16,7 @@ import (
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
const attributeViewRefreshInterval = 30 * time.Second
@ -2071,6 +2072,7 @@ func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles s
ID: userID,
Type: "user",
Attributes: map[string]any{},
Session: map[string]any{},
}
} else {
rctx.Logger().Warn("Failed to get subject for access control subject",
@ -2108,6 +2110,23 @@ func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles s
return subject, nil
}
func (a *App) BuildAccessControlSubjectForSession(rctx request.CTX, channelID string) (*model.Subject, *model.AppError) {
subject, appErr := a.BuildAccessControlSubject(rctx, rctx.Session().UserId, rctx.Session().Roles, channelID)
if appErr != nil {
return nil, appErr
}
attrs, err := a.Srv().Store().SessionAttribute().Get(rctx.Session().Id)
if err != nil {
if errors.Is(err, cache.ErrKeyNotFound) {
return subject, nil
}
return nil, model.NewAppError("BuildAccessControlSubjectForSession", "app.access_control.build_subject_for_session.get_session_attributes.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
subject.Session = attrs
return subject, nil
}
// GetSubjectChannelRole returns the channel-scoped role identifier
// (channel_admin / channel_user / channel_guest) for the given user in
// the given channel.

View file

@ -2319,6 +2319,41 @@ func TestGetRecommendedPublicChannelsForUser(t *testing.T) {
})
}
func TestBuildAccessControlSubjectForSession(t *testing.T) {
t.Run("returns subject without session attributes when none are cached", func(t *testing.T) {
th := Setup(t).InitBasic(t)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
subject, appErr := th.App.BuildAccessControlSubjectForSession(rctx, "")
require.Nil(t, appErr)
require.NotNil(t, subject)
assert.Equal(t, th.BasicUser.Id, subject.ID)
assert.Empty(t, subject.Session)
})
t.Run("populates session attributes from the cache", func(t *testing.T) {
th := Setup(t).InitBasic(t)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
require.NoError(t, th.App.Srv().Store().SessionAttribute().Refresh(session.Id, map[string]any{
model.SessionAttributesPropertyFieldIPAddress: "192.0.2.10",
model.SessionAttributesPropertyFieldUserAgentBrowserName: "Chrome",
}))
subject, appErr := th.App.BuildAccessControlSubjectForSession(rctx, "")
require.Nil(t, appErr)
require.NotNil(t, subject)
assert.Equal(t, "192.0.2.10", subject.Session[model.SessionAttributesPropertyFieldIPAddress])
assert.Equal(t, "Chrome", subject.Session[model.SessionAttributesPropertyFieldUserAgentBrowserName])
})
}
// TestFilterResponseToEditingRuleScope locks down the post-processing
// that turns a full-stack simulator response into a "this rule only"
// view. Upper-scoped blame entries (system_permission, peer_policy,

View file

@ -730,7 +730,13 @@ func (a *App) HasPermissionToFileAction(rctx request.CTX, userID string, roles s
return true
}
subject, appErr := a.BuildAccessControlSubject(rctx, userID, roles, channelID)
var subject *model.Subject
var appErr *model.AppError
if rctx.Session().UserId == userID {
subject, appErr = a.BuildAccessControlSubjectForSession(rctx, channelID)
} else {
subject, appErr = a.BuildAccessControlSubject(rctx, userID, roles, channelID)
}
if appErr != nil {
rctx.Logger().Info("Failed to build ABAC subject for file action evaluation",
mlog.String("user_id", userID),

View file

@ -1615,19 +1615,24 @@ func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) (*model.
return nil, nil
}
user, err := a.GetUser(userID)
if err != nil {
rctx.Logger().Warn("Failed to get user for file download permission filtering",
mlog.String("user_id", userID),
mlog.Err(err),
)
return nil, err
var subject *model.Subject
var appErr *model.AppError
if rctx.Session().UserId == userID {
subject, appErr = a.BuildAccessControlSubjectForSession(rctx, "")
} else {
user, err := a.GetUser(userID)
if err != nil {
rctx.Logger().Warn("Failed to get user for file download permission filtering",
mlog.String("user_id", userID),
mlog.Err(err),
)
return nil, err
}
// channelID is intentionally empty here: the subject is reused across many
// channels in the file-search loop. hasFileDownloadPermission attaches the
// channel-scoped role per-evaluation via attachChannelScopedRole.
subject, appErr = a.BuildAccessControlSubject(rctx, userID, user.Roles, "")
}
// channelID is intentionally empty here: the subject is reused across many
// channels in the file-search loop. hasFileDownloadPermission attaches the
// channel-scoped role per-evaluation via attachChannelScopedRole.
subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles, "")
if appErr != nil {
rctx.Logger().Warn("Failed to build ABAC subject for file search filtering",
mlog.String("user_id", userID),

View file

@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/avct/uasurfer"
"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/utils"
)
var requestProvidedSessionAttributeFieldNames = []string{
model.SessionAttributesPropertyFieldUserAgentPlatform,
model.SessionAttributesPropertyFieldUserAgentOS,
model.SessionAttributesPropertyFieldUserAgentBrowserName,
model.SessionAttributesPropertyFieldUserAgentBrowserVersion,
model.SessionAttributesPropertyFieldIPAddress,
}
func (a *App) RefreshRequestProvidedSessionAttributesIfNeeded(rctx request.CTX, r *http.Request) {
if !a.Config().FeatureFlags.SessionAttributes {
return
}
if l := a.License(); !model.MinimumEnterpriseAdvancedLicense(l) {
return
}
session := rctx.Session()
if session == nil || session.Id == "" || session.UserId == "" || r == nil {
return
}
if session.Local {
return
}
switch session.Props[model.SessionPropType] {
case model.SessionTypeUserAccessToken, model.SessionTypeCloudKey, model.SessionTypeRemoteclusterToken:
return
}
attrs := make(map[string]any, len(requestProvidedSessionAttributeFieldNames))
for _, name := range requestProvidedSessionAttributeFieldNames {
if v := a.getRequestProvidedSessionAttributeByName(r, name); v != "" {
attrs[name] = v
}
}
if len(attrs) == 0 {
return
}
if err := a.Srv().Store().SessionAttribute().Refresh(session.Id, attrs); err != nil {
rctx.Logger().Warn("Failed to refresh session attributes", mlog.Err(err))
}
}
func (a *App) getRequestProvidedSessionAttributeByName(r *http.Request, name string) string {
uaStr := r.UserAgent()
ua := uasurfer.Parse(uaStr)
switch name {
case model.SessionAttributesPropertyFieldUserAgentPlatform:
return getPlatformName(ua, uaStr)
case model.SessionAttributesPropertyFieldUserAgentOS:
return getOSName(ua, uaStr)
case model.SessionAttributesPropertyFieldUserAgentBrowserName:
return getBrowserName(ua, uaStr)
case model.SessionAttributesPropertyFieldUserAgentBrowserVersion:
return getBrowserVersion(ua, uaStr)
case model.SessionAttributesPropertyFieldIPAddress:
return utils.GetIPAddress(r, a.Config().ServiceSettings.TrustedProxyIPHeader)
}
return ""
}

View file

@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
const testUserAgentChrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"
func enableSessionAttributesCollection(t *testing.T, th *TestHelper) {
t.Helper()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.ConfigStore.SetReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.SessionAttributes = true })
th.ConfigStore.SetReadOnlyFF(true)
}
func newSessionAttributesRequest(t *testing.T, userAgent, remoteAddr string) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodGet, "/api/v4/test", nil)
if userAgent != "" {
r.Header.Set("User-Agent", userAgent)
}
if remoteAddr != "" {
r.RemoteAddr = remoteAddr
}
return r
}
func sessionAttributeValuesByFieldName(t *testing.T, th *TestHelper, sessionID string) map[string]string {
t.Helper()
attrs, err := th.App.Srv().Store().SessionAttribute().Get(sessionID)
if errors.Is(err, cache.ErrKeyNotFound) {
return map[string]string{}
}
require.NoError(t, err)
result := make(map[string]string, len(attrs))
for k, v := range attrs {
s, ok := v.(string)
require.True(t, ok, "expected string for session attribute %q", k)
result[k] = s
}
return result
}
func TestRefreshRequestProvidedSessionAttributesIfNeeded(t *testing.T) {
t.Run("skips when feature flag is disabled", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
require.Empty(t, sessionAttributeValuesByFieldName(t, th, session.Id))
})
t.Run("skips when license is missing", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.ConfigStore.SetReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.SessionAttributes = true })
th.ConfigStore.SetReadOnlyFF(true)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
require.Empty(t, sessionAttributeValuesByFieldName(t, th, session.Id))
})
t.Run("skips when session is local", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
session := &model.Session{Id: model.NewId(), UserId: th.BasicUser.Id, Local: true, Props: model.StringMap{}}
rctx := th.Context.WithSession(session)
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
require.Empty(t, sessionAttributeValuesByFieldName(t, th, session.Id))
})
t.Run("skips token-based session types", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
tokenTypes := []string{
model.SessionTypeUserAccessToken,
model.SessionTypeCloudKey,
model.SessionTypeRemoteclusterToken,
}
for _, sessionType := range tokenTypes {
t.Run(sessionType, func(t *testing.T) {
session, appErr := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
Props: model.StringMap{model.SessionPropType: sessionType},
})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
require.Empty(t, sessionAttributeValuesByFieldName(t, th, session.Id))
})
}
})
t.Run("skips when session id is empty", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
rctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
})
t.Run("skips when request is nil", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, nil)
require.Empty(t, sessionAttributeValuesByFieldName(t, th, session.Id))
})
t.Run("creates session attributes when none exist", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
r := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, r)
valuesByName := sessionAttributeValuesByFieldName(t, th, session.Id)
assert.Equal(t, "Macintosh", valuesByName[model.SessionAttributesPropertyFieldUserAgentPlatform])
assert.Equal(t, "Mac OS", valuesByName[model.SessionAttributesPropertyFieldUserAgentOS])
assert.Equal(t, "Chrome", valuesByName[model.SessionAttributesPropertyFieldUserAgentBrowserName])
assert.Equal(t, "60.0.3112", valuesByName[model.SessionAttributesPropertyFieldUserAgentBrowserVersion])
assert.Equal(t, "192.0.2.10", valuesByName[model.SessionAttributesPropertyFieldIPAddress])
})
t.Run("each call overwrites cached attributes with the latest request values", func(t *testing.T) {
th := Setup(t).InitBasic(t)
enableSessionAttributesCollection(t, th)
session, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, appErr)
rctx := th.Context.WithSession(session)
firstRequest := newSessionAttributesRequest(t, testUserAgentChrome, "192.0.2.10:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, firstRequest)
initial := sessionAttributeValuesByFieldName(t, th, session.Id)
assert.Equal(t, "192.0.2.10", initial[model.SessionAttributesPropertyFieldIPAddress])
assert.Equal(t, "Chrome", initial[model.SessionAttributesPropertyFieldUserAgentBrowserName])
const otherUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0"
secondRequest := newSessionAttributesRequest(t, otherUserAgent, "203.0.113.42:1234")
th.App.RefreshRequestProvidedSessionAttributesIfNeeded(rctx, secondRequest)
after := sessionAttributeValuesByFieldName(t, th, session.Id)
assert.Equal(t, "203.0.113.42", after[model.SessionAttributesPropertyFieldIPAddress])
assert.Equal(t, "Firefox", after[model.SessionAttributesPropertyFieldUserAgentBrowserName])
})
}
func TestGetRequestProvidedSessionAttributeByName(t *testing.T) {
th := Setup(t)
r := newSessionAttributesRequest(t, testUserAgentChrome, "198.51.100.7:5678")
t.Run("returns user agent platform", func(t *testing.T) {
assert.Equal(t, "Macintosh", th.App.getRequestProvidedSessionAttributeByName(r, model.SessionAttributesPropertyFieldUserAgentPlatform))
})
t.Run("returns user agent OS", func(t *testing.T) {
assert.Equal(t, "Mac OS", th.App.getRequestProvidedSessionAttributeByName(r, model.SessionAttributesPropertyFieldUserAgentOS))
})
t.Run("returns user agent browser name", func(t *testing.T) {
assert.Equal(t, "Chrome", th.App.getRequestProvidedSessionAttributeByName(r, model.SessionAttributesPropertyFieldUserAgentBrowserName))
})
t.Run("returns user agent browser version", func(t *testing.T) {
assert.Equal(t, "60.0.3112", th.App.getRequestProvidedSessionAttributeByName(r, model.SessionAttributesPropertyFieldUserAgentBrowserVersion))
})
t.Run("returns IP address", func(t *testing.T) {
assert.Equal(t, "198.51.100.7", th.App.getRequestProvidedSessionAttributeByName(r, model.SessionAttributesPropertyFieldIPAddress))
})
t.Run("returns empty string for unknown name", func(t *testing.T) {
assert.Empty(t, th.App.getRequestProvidedSessionAttributeByName(r, "unknown_attribute"))
})
}

View file

@ -82,6 +82,9 @@ const (
TemporaryPostCacheSize = 10000
TemporaryPostCacheMinutes = 60
SessionAttributeCacheSize = model.SessionCacheSize
SessionAttributeCacheSec = 30
)
var clearCacheMessageData = []byte("")
@ -152,6 +155,9 @@ type LocalCacheStore struct {
temporaryPost LocalCacheTemporaryPostStore
temporaryPostCache cache.Cache
sessionAttribute LocalCacheSessionAttributeStore
sessionAttributeCache cache.Cache
}
func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterface, cluster einterfaces.ClusterInterface, cacheProvider cache.Provider, logger mlog.LoggerIFace) (localCacheStore LocalCacheStore, err error) {
@ -456,6 +462,17 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
}
localCacheStore.temporaryPost = LocalCacheTemporaryPostStore{TemporaryPostStore: baseStore.TemporaryPost(), rootStore: &localCacheStore}
// Session Attributes
if localCacheStore.sessionAttributeCache, err = cacheProvider.NewCache(&cache.CacheOptions{
Size: SessionAttributeCacheSize,
Name: "SessionAttribute",
DefaultExpiry: SessionAttributeCacheSec * time.Second,
InvalidateClusterEvent: model.ClusterEventInvalidateCacheForSessionAttributes,
}); err != nil {
return
}
localCacheStore.sessionAttribute = LocalCacheSessionAttributeStore{SessionAttributeStore: baseStore.SessionAttribute(), rootStore: &localCacheStore}
if cluster != nil {
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForReactions, localCacheStore.reaction.handleClusterInvalidateReaction)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForRoles, localCacheStore.role.handleClusterInvalidateRole)
@ -485,6 +502,7 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForContentFlagging, localCacheStore.contentFlagging.handleClusterInvalidateContentFlagging)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForReadReceipts, localCacheStore.readReceipt.handleClusterInvalidateReadReceipts)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForTemporaryPosts, localCacheStore.temporaryPost.handleClusterInvalidateTemporaryPosts)
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForSessionAttributes, localCacheStore.sessionAttribute.handleClusterInvalidateSessionAttributes)
}
return
}
@ -549,6 +567,10 @@ func (s LocalCacheStore) TemporaryPost() store.TemporaryPostStore {
return s.temporaryPost
}
func (s LocalCacheStore) SessionAttribute() store.SessionAttributeStore {
return &s.sessionAttribute
}
func (s LocalCacheStore) DropAllTables() {
s.Invalidate()
s.Store.DropAllTables()
@ -687,6 +709,7 @@ func (s *LocalCacheStore) Invalidate() {
s.doClearCacheCluster(s.readReceiptCache)
s.doClearCacheCluster(s.readReceiptPostReadersCache)
s.doClearCacheCluster(s.temporaryPostCache)
s.doClearCacheCluster(s.sessionAttributeCache)
}
// allocateCacheTargets is used to fill target value types

View file

@ -204,6 +204,9 @@ func getMockStore(t *testing.T) *mocks.Store {
mockContentFlaggingStore := mocks.ContentFlaggingStore{}
mockStore.On("ContentFlagging").Return(&mockContentFlaggingStore)
mockSessionAttributeStore := mocks.SessionAttributeStore{}
mockStore.On("SessionAttribute").Return(&mockSessionAttributeStore)
mockReadReceiptStore := &mocks.ReadReceiptStore{}
mockStore.On("ReadReceipt").Return(mockReadReceiptStore)

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package localcachelayer
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type sessionAttributeEntry struct {
Attrs map[string]any
}
type LocalCacheSessionAttributeStore struct {
store.SessionAttributeStore
rootStore *LocalCacheStore
}
func (s *LocalCacheSessionAttributeStore) handleClusterInvalidateSessionAttributes(_ *model.ClusterMessage) {
if err := s.rootStore.sessionAttributeCache.Purge(); err != nil {
s.rootStore.logger.Warn("failed to purge session attribute cache", mlog.Err(err))
}
}
func (s *LocalCacheSessionAttributeStore) Refresh(sessionID string, attrs map[string]any) error {
if err := s.rootStore.sessionAttributeCache.SetWithDefaultExpiry(sessionID, &sessionAttributeEntry{Attrs: attrs}); err != nil {
s.rootStore.logger.Warn("failed to set session attribute cache", mlog.Err(err))
return err
}
return nil
}
func (s *LocalCacheSessionAttributeStore) Get(sessionID string) (map[string]any, error) {
var entry *sessionAttributeEntry
if err := s.rootStore.sessionAttributeCache.Get(sessionID, &entry); err != nil {
return nil, err
}
return entry.Attrs, nil
}

View file

@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package localcachelayer
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
func TestSessionAttributeStoreCache(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
t.Run("Refresh writes attributes that Get returns", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
sessionID := model.NewId()
err = cachedStore.SessionAttribute().Refresh(sessionID, map[string]any{
model.SessionAttributesPropertyFieldUserAgentBrowserName: "Chrome",
model.SessionAttributesPropertyFieldIPAddress: "192.0.2.10",
})
require.NoError(t, err)
got, err := cachedStore.SessionAttribute().Get(sessionID)
require.NoError(t, err)
require.Equal(t, "Chrome", got[model.SessionAttributesPropertyFieldUserAgentBrowserName])
require.Equal(t, "192.0.2.10", got[model.SessionAttributesPropertyFieldIPAddress])
})
t.Run("Refresh overwrites existing attributes", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
sessionID := model.NewId()
require.NoError(t, cachedStore.SessionAttribute().Refresh(sessionID, map[string]any{
model.SessionAttributesPropertyFieldUserAgentBrowserName: "Chrome",
}))
require.NoError(t, cachedStore.SessionAttribute().Refresh(sessionID, map[string]any{
model.SessionAttributesPropertyFieldUserAgentBrowserName: "Firefox",
}))
got, err := cachedStore.SessionAttribute().Get(sessionID)
require.NoError(t, err)
require.Equal(t, "Firefox", got[model.SessionAttributesPropertyFieldUserAgentBrowserName])
})
t.Run("Get on missing session returns ErrKeyNotFound", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
got, err := cachedStore.SessionAttribute().Get(model.NewId())
require.ErrorIs(t, err, cache.ErrKeyNotFound)
require.Nil(t, got)
})
t.Run("cluster invalidation with clear-cache marker purges every entry", func(t *testing.T) {
mockStore := getMockStore(t)
mockCacheProvider := getMockCacheProvider()
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
sessionA := model.NewId()
sessionB := model.NewId()
require.NoError(t, cachedStore.SessionAttribute().Refresh(sessionA, map[string]any{
model.SessionAttributesPropertyFieldIPAddress: "192.0.2.10",
}))
require.NoError(t, cachedStore.SessionAttribute().Refresh(sessionB, map[string]any{
model.SessionAttributesPropertyFieldIPAddress: "203.0.113.42",
}))
cachedStore.sessionAttribute.handleClusterInvalidateSessionAttributes(&model.ClusterMessage{
Event: model.ClusterEventInvalidateCacheForSessionAttributes,
Data: clearCacheMessageData,
})
_, err = cachedStore.SessionAttribute().Get(sessionA)
require.ErrorIs(t, err, cache.ErrKeyNotFound)
_, err = cachedStore.SessionAttribute().Get(sessionB)
require.ErrorIs(t, err, cache.ErrKeyNotFound)
})
}

View file

@ -65,6 +65,7 @@ type RetryLayer struct {
ScheduledPostStore store.ScheduledPostStore
SchemeStore store.SchemeStore
SessionStore store.SessionStore
SessionAttributeStore store.SessionAttributeStore
SharedChannelStore store.SharedChannelStore
StatusStore store.StatusStore
SystemStore store.SystemStore
@ -261,6 +262,10 @@ func (s *RetryLayer) Session() store.SessionStore {
return s.SessionStore
}
func (s *RetryLayer) SessionAttribute() store.SessionAttributeStore {
return s.SessionAttributeStore
}
func (s *RetryLayer) SharedChannel() store.SharedChannelStore {
return s.SharedChannelStore
}
@ -542,6 +547,11 @@ type RetryLayerSessionStore struct {
Root *RetryLayer
}
type RetryLayerSessionAttributeStore struct {
store.SessionAttributeStore
Root *RetryLayer
}
type RetryLayerSharedChannelStore struct {
store.SharedChannelStore
Root *RetryLayer
@ -13073,6 +13083,48 @@ func (s *RetryLayerSessionStore) UpdateRoles(userID string, roles string) (strin
}
func (s *RetryLayerSessionAttributeStore) Get(sessionID string) (map[string]any, error) {
tries := 0
for {
result, err := s.SessionAttributeStore.Get(sessionID)
if err == nil {
return result, nil
}
if !isRepeatableError(err) {
return result, err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return result, err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerSessionAttributeStore) Refresh(sessionID string, attrs map[string]any) error {
tries := 0
for {
err := s.SessionAttributeStore.Refresh(sessionID, attrs)
if err == nil {
return nil
}
if !isRepeatableError(err) {
return err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerSharedChannelStore) Delete(channelID string) (bool, error) {
tries := 0
@ -18907,6 +18959,7 @@ func New(childStore store.Store) *RetryLayer {
newStore.ScheduledPostStore = &RetryLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore}
newStore.SchemeStore = &RetryLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore}
newStore.SessionStore = &RetryLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore}
newStore.SessionAttributeStore = &RetryLayerSessionAttributeStore{SessionAttributeStore: childStore.SessionAttribute(), Root: &newStore}
newStore.SharedChannelStore = &RetryLayerSharedChannelStore{SharedChannelStore: childStore.SharedChannel(), Root: &newStore}
newStore.StatusStore = &RetryLayerStatusStore{StatusStore: childStore.Status(), Root: &newStore}
newStore.SystemStore = &RetryLayerSystemStore{SystemStore: childStore.System(), Root: &newStore}

View file

@ -47,6 +47,7 @@ func genStore() *mocks.Store {
mock.On("Role").Return(&mocks.RoleStore{})
mock.On("Scheme").Return(&mocks.SchemeStore{})
mock.On("Session").Return(&mocks.SessionStore{})
mock.On("SessionAttribute").Return(&mocks.SessionAttributeStore{})
mock.On("Status").Return(&mocks.StatusStore{})
mock.On("System").Return(&mocks.SystemStore{})
mock.On("Team").Return(&mocks.TeamStore{})

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// SqlSessionAttributeStore is a no-op SessionAttributeStore: session
// attributes are never persisted to SQL. The localcachelayer wraps this and
// is the source of truth at runtime; the DB-backed shim exists only to
// satisfy the store.Store interface.
type SqlSessionAttributeStore struct {
*SqlStore
}
func newSqlSessionAttributeStore(sqlStore *SqlStore) store.SessionAttributeStore {
return &SqlSessionAttributeStore{SqlStore: sqlStore}
}
func (s *SqlSessionAttributeStore) Refresh(_ string, _ map[string]any) error {
return nil
}
func (s *SqlSessionAttributeStore) Get(_ string) (map[string]any, error) {
return map[string]any{}, nil
}

View file

@ -113,6 +113,7 @@ type SqlStoreStores struct {
propertyValue store.PropertyValueStore
accessControlPolicy store.AccessControlPolicyStore
Attributes store.AttributesStore
sessionAttribute store.SessionAttributeStore
autotranslation store.AutoTranslationStore
ContentFlagging store.ContentFlaggingStore
recap store.RecapStore
@ -301,6 +302,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
store.stores.propertyValue = newPropertyValueStore(store)
store.stores.accessControlPolicy = newSqlAccessControlPolicyStore(store, metrics)
store.stores.Attributes = newSqlAttributesStore(store, metrics)
store.stores.sessionAttribute = newSqlSessionAttributeStore(store)
store.stores.autotranslation = newSqlAutoTranslationStore(store)
store.stores.ContentFlagging = newContentFlaggingStore(store)
store.stores.recap = newSqlRecapStore(store)
@ -943,6 +945,10 @@ func (ss *SqlStore) Attributes() store.AttributesStore {
return ss.stores.Attributes
}
func (ss *SqlStore) SessionAttribute() store.SessionAttributeStore {
return ss.stores.sessionAttribute
}
func (ss *SqlStore) AutoTranslation() store.AutoTranslationStore {
return ss.stores.autotranslation
}

View file

@ -98,6 +98,7 @@ type Store interface {
PropertyValue() PropertyValueStore
AccessControlPolicy() AccessControlPolicyStore
Attributes() AttributesStore
SessionAttribute() SessionAttributeStore
AutoTranslation() AutoTranslationStore
GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error)
ContentFlagging() ContentFlaggingStore
@ -1226,6 +1227,11 @@ type AttributesStore interface {
GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error)
}
type SessionAttributeStore interface {
Refresh(sessionID string, attrs map[string]any) error
Get(sessionID string) (map[string]any, error)
}
type AutoTranslationStore interface {
IsUserEnabled(userID, channelID string) (bool, error)
GetUserLanguage(userID, channelID string) (string, error)

View file

@ -0,0 +1,74 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make store-mocks`.
package mocks
import mock "github.com/stretchr/testify/mock"
// SessionAttributeStore is an autogenerated mock type for the SessionAttributeStore type
type SessionAttributeStore struct {
mock.Mock
}
// Get provides a mock function with given fields: sessionID
func (_m *SessionAttributeStore) Get(sessionID string) (map[string]interface{}, error) {
ret := _m.Called(sessionID)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(string) (map[string]interface{}, error)); ok {
return rf(sessionID)
}
if rf, ok := ret.Get(0).(func(string) map[string]interface{}); ok {
r0 = rf(sessionID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(sessionID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Refresh provides a mock function with given fields: sessionID, attrs
func (_m *SessionAttributeStore) Refresh(sessionID string, attrs map[string]interface{}) error {
ret := _m.Called(sessionID, attrs)
if len(ret) == 0 {
panic("no return value specified for Refresh")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, map[string]interface{}) error); ok {
r0 = rf(sessionID, attrs)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewSessionAttributeStore creates a new instance of SessionAttributeStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewSessionAttributeStore(t interface {
mock.TestingT
Cleanup(func())
}) *SessionAttributeStore {
mock := &SessionAttributeStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -1235,6 +1235,26 @@ func (_m *Store) Session() store.SessionStore {
return r0
}
// SessionAttribute provides a mock function with no fields
func (_m *Store) SessionAttribute() store.SessionAttributeStore {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for SessionAttribute")
}
var r0 store.SessionAttributeStore
if rf, ok := ret.Get(0).(func() store.SessionAttributeStore); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(store.SessionAttributeStore)
}
}
return r0
}
// SharedChannel provides a mock function with no fields
func (_m *Store) SharedChannel() store.SharedChannelStore {
ret := _m.Called()

View file

@ -71,6 +71,7 @@ type Store struct {
PropertyValueStore mocks.PropertyValueStore
AccessControlPolicyStore mocks.AccessControlPolicyStore
AttributesStore mocks.AttributesStore
SessionAttributeStore mocks.SessionAttributeStore
AutoTranslationStore mocks.AutoTranslationStore
ContentFlaggingStore mocks.ContentFlaggingStore
RecapStore mocks.RecapStore
@ -168,6 +169,9 @@ func (s *Store) AccessControlPolicy() store.AccessControlPolicyStore {
func (s *Store) Attributes() store.AttributesStore {
return &s.AttributesStore
}
func (s *Store) SessionAttribute() store.SessionAttributeStore {
return &s.SessionAttributeStore
}
func (s *Store) AutoTranslation() store.AutoTranslationStore {
return &s.AutoTranslationStore
}
@ -245,6 +249,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
&s.ScheduledPostStore,
&s.AccessControlPolicyStore,
&s.AttributesStore,
&s.SessionAttributeStore,
&s.AutoTranslationStore,
&s.ContentFlaggingStore,
&s.RecapStore,

View file

@ -64,6 +64,7 @@ type TimerLayer struct {
ScheduledPostStore store.ScheduledPostStore
SchemeStore store.SchemeStore
SessionStore store.SessionStore
SessionAttributeStore store.SessionAttributeStore
SharedChannelStore store.SharedChannelStore
StatusStore store.StatusStore
SystemStore store.SystemStore
@ -260,6 +261,10 @@ func (s *TimerLayer) Session() store.SessionStore {
return s.SessionStore
}
func (s *TimerLayer) SessionAttribute() store.SessionAttributeStore {
return s.SessionAttributeStore
}
func (s *TimerLayer) SharedChannel() store.SharedChannelStore {
return s.SharedChannelStore
}
@ -541,6 +546,11 @@ type TimerLayerSessionStore struct {
Root *TimerLayer
}
type TimerLayerSessionAttributeStore struct {
store.SessionAttributeStore
Root *TimerLayer
}
type TimerLayerSharedChannelStore struct {
store.SharedChannelStore
Root *TimerLayer
@ -10351,6 +10361,38 @@ func (s *TimerLayerSessionStore) UpdateRoles(userID string, roles string) (strin
return result, err
}
func (s *TimerLayerSessionAttributeStore) Get(sessionID string) (map[string]any, error) {
start := time.Now()
result, err := s.SessionAttributeStore.Get(sessionID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("SessionAttributeStore.Get", success, elapsed)
}
return result, err
}
func (s *TimerLayerSessionAttributeStore) Refresh(sessionID string, attrs map[string]any) error {
start := time.Now()
err := s.SessionAttributeStore.Refresh(sessionID, attrs)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("SessionAttributeStore.Refresh", success, elapsed)
}
return err
}
func (s *TimerLayerSharedChannelStore) Delete(channelID string) (bool, error) {
start := time.Now()
@ -14956,6 +14998,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay
newStore.ScheduledPostStore = &TimerLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore}
newStore.SchemeStore = &TimerLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore}
newStore.SessionStore = &TimerLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore}
newStore.SessionAttributeStore = &TimerLayerSessionAttributeStore{SessionAttributeStore: childStore.SessionAttribute(), Root: &newStore}
newStore.SharedChannelStore = &TimerLayerSharedChannelStore{SharedChannelStore: childStore.SharedChannel(), Root: &newStore}
newStore.StatusStore = &TimerLayerStatusStore{StatusStore: childStore.Status(), Root: &newStore}
newStore.SystemStore = &TimerLayerSystemStore{SystemStore: childStore.System(), Root: &newStore}

View file

@ -355,6 +355,10 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
if c.Err == nil {
c.App.RefreshRequestProvidedSessionAttributesIfNeeded(c.AppContext, r)
}
if c.Err == nil {
h.HandleFunc(c, w, r)
}

View file

@ -536,6 +536,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf
model.ClusterEventInvalidateCacheForPostsUsage,
model.ClusterEventInvalidateCacheForTeams,
model.ClusterEventInvalidateCacheForContentFlagging,
model.ClusterEventInvalidateCacheForSessionAttributes,
model.ClusterEventClearSessionCacheForAllUsers,
model.ClusterEventInstallPlugin,
model.ClusterEventRemovePlugin,

View file

@ -5258,6 +5258,10 @@
"id": "app.access_control.build_subject.group_id.app_error",
"translation": "Failed to retrieve the access control attribute group."
},
{
"id": "app.access_control.build_subject_for_session.get_session_attributes.app_error",
"translation": "Failed to retrieve the session attributes."
},
{
"id": "app.access_control.get_channel_role.app_error",
"translation": "Unable to get channel role for the user. Please try again."
@ -10850,6 +10854,10 @@
"id": "model.access_policy.is_valid.scope_id_without_scope.app_error",
"translation": "Scope ID cannot be set without a scope."
},
{
"id": "model.access_policy.is_valid.session_attribute_on_membership.app_error",
"translation": "Membership rules cannot reference session attributes (user.session). Use a permission policy instead."
},
{
"id": "model.access_policy.is_valid.type.app_error",
"translation": "Invalid policy type."

View file

@ -378,6 +378,9 @@ func (p *AccessControlPolicy) accessPolicyVersionV0_3() *AppError {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400)
}
}
if slices.Contains(rule.Actions, AccessControlPolicyActionMembership) && strings.Contains(rule.Expression, "user.session") {
return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.session_attribute_on_membership.app_error", nil, "", 400)
}
}
return nil

View file

@ -494,6 +494,91 @@ func TestAccessPolicyVersionV0_3(t *testing.T) {
require.NotNil(t, err)
require.Equal(t, "model.access_policy.is_valid.rules_imports.app_error", err.Id)
})
t.Run("membership rule rejects session attribute reference", func(t *testing.T) {
policy := &AccessControlPolicy{
ID: NewId(),
Type: AccessControlPolicyTypeParent,
Name: "Parent",
Revision: 0,
Version: AccessControlPolicyVersionV0_3,
Rules: []AccessControlPolicyRule{{
Actions: []string{AccessControlPolicyActionMembership},
Expression: "user.session.ip_address == \"10.0.0.1\"",
}},
}
err := policy.accessPolicyVersionV0_3()
require.NotNil(t, err)
require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id)
})
t.Run("permission rule allows session attribute reference", func(t *testing.T) {
policy := &AccessControlPolicy{
ID: NewId(),
Type: AccessControlPolicyTypePermission,
Name: "Permission",
Revision: 0,
Version: AccessControlPolicyVersionV0_3,
Roles: []string{"system_user"},
Rules: []AccessControlPolicyRule{{
Actions: []string{AccessControlPolicyActionUploadFileAttachment},
Expression: "user.session.ip_address == \"10.0.0.1\"",
}},
}
require.Nil(t, policy.accessPolicyVersionV0_3())
})
t.Run("mixed-action rule rejects session attribute reference", func(t *testing.T) {
policy := &AccessControlPolicy{
ID: NewId(),
Type: AccessControlPolicyTypeParent,
Name: "Parent",
Revision: 0,
Version: AccessControlPolicyVersionV0_3,
Rules: []AccessControlPolicyRule{{
Actions: []string{
AccessControlPolicyActionMembership,
AccessControlPolicyActionUploadFileAttachment,
},
Expression: "user.session.ip_address == \"10.0.0.1\"",
}},
}
err := policy.accessPolicyVersionV0_3()
require.NotNil(t, err)
require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id)
})
t.Run("membership rule without session reference is accepted", func(t *testing.T) {
policy := &AccessControlPolicy{
ID: NewId(),
Type: AccessControlPolicyTypeParent,
Name: "Parent",
Revision: 0,
Version: AccessControlPolicyVersionV0_3,
Rules: []AccessControlPolicyRule{{
Actions: []string{AccessControlPolicyActionMembership},
Expression: "user.attributes.team == \"eng\"",
}},
}
require.Nil(t, policy.accessPolicyVersionV0_3())
})
t.Run("membership rule rejects user.session inside string literal (lexical check)", func(t *testing.T) {
policy := &AccessControlPolicy{
ID: NewId(),
Type: AccessControlPolicyTypeParent,
Name: "Parent",
Revision: 0,
Version: AccessControlPolicyVersionV0_3,
Rules: []AccessControlPolicyRule{{
Actions: []string{AccessControlPolicyActionMembership},
Expression: "user.attributes.note == \"see user.session for context\"",
}},
}
err := policy.accessPolicyVersionV0_3()
require.NotNil(t, err)
require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id)
})
}
func TestAccessPolicyVersionV0_4(t *testing.T) {

View file

@ -43,6 +43,7 @@ const (
ClusterEventInvalidateCacheForPostsUsage ClusterEvent = "inv_posts_usage"
ClusterEventInvalidateCacheForTeams ClusterEvent = "inv_teams"
ClusterEventInvalidateCacheForContentFlagging ClusterEvent = "inv_content_flagging"
ClusterEventInvalidateCacheForSessionAttributes ClusterEvent = "inv_session_attributes"
ClusterEventInvalidateCacheForAutoTranslation ClusterEvent = "inv_autotranslation"
ClusterEventInvalidateCacheForReadReceipts ClusterEvent = "inv_read_receipts"
ClusterEventInvalidateCacheForTemporaryPosts ClusterEvent = "inv_temporary_posts"

View file

@ -140,6 +140,9 @@ type FeatureFlags struct {
// ManagedChannelCategories enables server-side managed sidebar category enforcement (Enterprise).
ManagedChannelCategories bool
// Enable collection of request-provided session attributes (user agent, IP address, etc.).
SessionAttributes bool
// FEATURE_FLAG_REMOVAL: DiscoverableChannels - Remove this when the feature is GA.
// Gates the per-channel Discoverable toggle and the channel-join-request flow that lets
// non-members find a private channel in Browse Channels and request to join it.
@ -205,6 +208,8 @@ func (f *FeatureFlags) SetDefaults() {
f.ManagedChannelCategories = false
f.SessionAttributes = false
f.DiscoverableChannels = false
f.MobileEphemeralMode = false

View file

@ -35,6 +35,12 @@ const (
SessionPropIsGuest = "is_guest"
SessionActivityTimeout = 1000 * 60 * 5 // 5 minutes
SessionUserAccessTokenExpiryHours = 100 * 365 * 24 // 100 years
SessionAttributesPropertyFieldUserAgentPlatform = "user_agent_platform"
SessionAttributesPropertyFieldUserAgentOS = "user_agent_os"
SessionAttributesPropertyFieldUserAgentBrowserName = "user_agent_browser_name"
SessionAttributesPropertyFieldUserAgentBrowserVersion = "user_agent_browser_version"
SessionAttributesPropertyFieldIPAddress = "ip_address"
)
//msgp:tuple StringMap

View file

@ -7,6 +7,7 @@ import type {Dispatch} from 'redux';
import {getAccessControlPolicy as fetchPolicy, createAccessControlPolicy as createPolicy, deleteAccessControlPolicy as deletePolicy} from 'mattermost-redux/actions/access_control';
import {getAccessControlSettings, getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control';
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {setNavigationBlocked} from 'actions/admin_actions.jsx';
@ -30,6 +31,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
policy,
policyId,
accessControlSettings: config,
sessionAttributesEnabled: getFeatureFlagValue(state, 'SessionAttributes') === 'true',
};
}

View file

@ -93,6 +93,7 @@ export interface PermissionPolicyDetailsProps {
policy?: AccessControlPolicy;
policyId?: string;
accessControlSettings: AccessControlSettings;
sessionAttributesEnabled: boolean;
actions: PolicyActions;
}
@ -113,6 +114,7 @@ function PermissionPolicyDetails({
policyId,
actions,
accessControlSettings,
sessionAttributesEnabled,
}: PermissionPolicyDetailsProps): JSX.Element {
const [policyName, setPolicyName] = useState(policy?.name || '');
const [expression, setExpression] = useState(policy?.rules?.[0]?.expression || '');
@ -140,7 +142,9 @@ function PermissionPolicyDetails({
// the channel-settings Permissions Policy tab.
const policySimulationEnabled = useSelector(isPolicySimulationEnabled);
const noUsableAttributes = attributesLoaded && !hasUsableAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes);
// Permission policies can reference session attributes (e.g. user.session.ip_address),
// so the editor stays usable even without any configured user attributes when SessionAttributes is on.
const noUsableAttributes = attributesLoaded && !sessionAttributesEnabled && !hasUsableAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes);
useEffect(() => {
loadPage().finally(() => setPageLoaded(true));

View file

@ -319,7 +319,6 @@
"version": "26.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -527,7 +526,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -2237,7 +2235,6 @@
"integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/compat-data": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
@ -2591,7 +2588,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -2615,7 +2611,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2808,7 +2803,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -5035,7 +5029,6 @@
"integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/core": "^0.16.13"
@ -5078,7 +5071,6 @@
"integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -5093,7 +5085,6 @@
"integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -5122,7 +5113,6 @@
"integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13",
@ -5172,7 +5162,6 @@
"integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -5316,7 +5305,6 @@
"integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -5331,7 +5319,6 @@
"integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -5349,7 +5336,6 @@
"integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@ -6092,8 +6078,7 @@
"version": "0.1.53",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.53.tgz",
"integrity": "sha512-MPcwJ9bKWxOxppxoqYCK5BW/a9qkaozxQr/fTdu55TLBjV5t0W6EPVa+msnFLr2iStYsYVnHwEo3fArsX0Bnew==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@mattermost/components": {
"resolved": "platform/components",
@ -7054,7 +7039,6 @@
"integrity": "sha512-a0CgrW5A5kwuSu5J1RFRoMQaMs9yagvfH2jJMYVw56+/7NRI4KOtu612SG9Y1ERWfY55ZwzyFxtLWvD6LO+Anw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@mischnic/json-sourcemap": "^0.1.1",
"@parcel/cache": "2.16.4",
@ -9083,7 +9067,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@ -9335,7 +9318,6 @@
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@ -9407,7 +9389,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -9528,7 +9509,6 @@
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
@ -9972,7 +9952,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.64.tgz",
"integrity": "sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@ -10243,7 +10222,6 @@
"integrity": "sha512-DqVpl8R0vbhVSop4120UHtGrFmHuPeoDwF4hDT0kPJTY8ty0SI38RV3VhCMsWigMUXG+kCXu7vMRqMFNy6eQgA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/hoist-non-react-statics": "*",
"@types/react": "*",
@ -10355,7 +10333,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@ -11048,7 +11025,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -12100,7 +12076,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -12477,8 +12452,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/clean-css": {
"version": "5.3.3",
@ -12929,6 +12903,7 @@
"integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@ -13433,7 +13408,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.11"
},
@ -14249,7 +14223,6 @@
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -14882,7 +14855,6 @@
"integrity": "sha512-MeVXdReleBTdkz/bvcQMSnCXGi+c9kvy51IpinjnJgutl3YTHWsDdke7Z1ufZpGfDG8xduBDKyjtB9JH1eBKIQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlast": "^1.2.4",
@ -14916,7 +14888,6 @@
"integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -17838,7 +17809,6 @@
"integrity": "sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.1.3",
"@jest/types": "30.0.5",
@ -20424,8 +20394,7 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
@ -20952,8 +20921,7 @@
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/monaco-editor-webpack-plugin": {
"version": "7.1.0",
@ -22122,7 +22090,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -22261,7 +22228,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -22436,7 +22402,6 @@
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -22761,7 +22726,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -22882,7 +22846,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@ -22962,8 +22925,7 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-is-18": {
"name": "react-is",
@ -23369,8 +23331,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-batched-actions": {
"version": "0.5.0",
@ -23784,7 +23745,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -24105,7 +24065,6 @@
"integrity": "sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
@ -24216,7 +24175,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -24571,8 +24529,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shallow-equals/-/shallow-equals-1.0.0.tgz",
"integrity": "sha512-xd/FKcdmfmMbyYCca3QTVEJtqUOGuajNzvAX6nt8dXILwjAIEkfHc4hI8/JMGApAmb7VeULO0Q30NTxnbH/15g==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
@ -25413,7 +25370,6 @@
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.7.tgz",
"integrity": "sha512-JL1b4A79OGqav4TxkrNsuuQfy6ZnrpyQx6hBDQ3Hd3JyuR2IQuVNBpF+FCEWFNZpN5hj+fhkaEVWteVJ18f0tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.0.0",
"@babel/traverse": "^7.4.5",
@ -25482,7 +25438,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.1",
"@csstools/css-tokenizer": "^3.0.1",
@ -26178,7 +26133,6 @@
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@popperjs/core": "^2.9.0"
}
@ -26388,8 +26342,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/turndown": {
"version": "7.2.2",
@ -26527,7 +26480,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -26962,7 +26914,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -27011,7 +26962,6 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",
@ -28039,7 +27989,6 @@
"version": "26.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -28290,7 +28239,6 @@
"version": "26.1.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",

View file

@ -135,6 +135,7 @@ export type ClientConfig = {
FeatureFlagContentFlagging: string;
FeatureFlagClassificationMarkings: string;
FeatureFlagManagedChannelCategories: string;
FeatureFlagSessionAttributes: string;
ForgotPasswordLink: string;
GiphySdkKey: string;