From d9c1388461553fbb6d0dca7cf2fba963265b6096 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Tue, 26 May 2026 11:24:03 -0400 Subject: [PATCH] [MM-68649] Add Session Attributes from user agent for use in Permission Policies (#36511) * [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 Co-authored-by: maria.nunez --- server/channels/app/access_control.go | 19 ++ server/channels/app/access_control_test.go | 35 +++ server/channels/app/authorization.go | 8 +- server/channels/app/file.go | 29 ++- server/channels/app/session_attributes.go | 78 ++++++ .../channels/app/session_attributes_test.go | 224 ++++++++++++++++++ .../channels/store/localcachelayer/layer.go | 23 ++ .../store/localcachelayer/main_test.go | 3 + .../session_attribute_layer.go | 41 ++++ .../session_attribute_layer_test.go | 94 ++++++++ .../channels/store/retrylayer/retrylayer.go | 53 +++++ .../store/retrylayer/retrylayer_test.go | 1 + .../store/sqlstore/session_attribute_store.go | 28 +++ server/channels/store/sqlstore/store.go | 6 + server/channels/store/store.go | 6 + .../storetest/mocks/SessionAttributeStore.go | 74 ++++++ .../channels/store/storetest/mocks/Store.go | 20 ++ server/channels/store/storetest/store.go | 5 + .../channels/store/timerlayer/timerlayer.go | 43 ++++ server/channels/web/handlers.go | 4 + server/enterprise/metrics/metrics.go | 1 + server/i18n/en.json | 8 + server/public/model/access_policy.go | 3 + server/public/model/access_policy_test.go | 85 +++++++ server/public/model/cluster_message.go | 1 + server/public/model/feature_flags.go | 5 + server/public/model/session.go | 6 + .../policy_details/index.ts | 2 + .../permission_policy_details.tsx | 6 +- webapp/package-lock.json | 70 +----- webapp/platform/types/src/config.ts | 1 + 31 files changed, 907 insertions(+), 75 deletions(-) create mode 100644 server/channels/app/session_attributes.go create mode 100644 server/channels/app/session_attributes_test.go create mode 100644 server/channels/store/localcachelayer/session_attribute_layer.go create mode 100644 server/channels/store/localcachelayer/session_attribute_layer_test.go create mode 100644 server/channels/store/sqlstore/session_attribute_store.go create mode 100644 server/channels/store/storetest/mocks/SessionAttributeStore.go diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index b2265864375..62a4392da7f 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -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. diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go index 79027150cf4..94f8191cdcd 100644 --- a/server/channels/app/access_control_test.go +++ b/server/channels/app/access_control_test.go @@ -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, diff --git a/server/channels/app/authorization.go b/server/channels/app/authorization.go index 0f9c68755bf..a110241f32d 100644 --- a/server/channels/app/authorization.go +++ b/server/channels/app/authorization.go @@ -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), diff --git a/server/channels/app/file.go b/server/channels/app/file.go index 35d443d6cd1..712f2254c31 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -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), diff --git a/server/channels/app/session_attributes.go b/server/channels/app/session_attributes.go new file mode 100644 index 00000000000..1245c43d14b --- /dev/null +++ b/server/channels/app/session_attributes.go @@ -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 "" +} diff --git a/server/channels/app/session_attributes_test.go b/server/channels/app/session_attributes_test.go new file mode 100644 index 00000000000..a4d154a1a67 --- /dev/null +++ b/server/channels/app/session_attributes_test.go @@ -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")) + }) +} diff --git a/server/channels/store/localcachelayer/layer.go b/server/channels/store/localcachelayer/layer.go index 6de4e9f4a33..5798b85b3fa 100644 --- a/server/channels/store/localcachelayer/layer.go +++ b/server/channels/store/localcachelayer/layer.go @@ -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 diff --git a/server/channels/store/localcachelayer/main_test.go b/server/channels/store/localcachelayer/main_test.go index 4fe732d6b5e..e8f5cacaf52 100644 --- a/server/channels/store/localcachelayer/main_test.go +++ b/server/channels/store/localcachelayer/main_test.go @@ -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) diff --git a/server/channels/store/localcachelayer/session_attribute_layer.go b/server/channels/store/localcachelayer/session_attribute_layer.go new file mode 100644 index 00000000000..825fe2e6624 --- /dev/null +++ b/server/channels/store/localcachelayer/session_attribute_layer.go @@ -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 +} diff --git a/server/channels/store/localcachelayer/session_attribute_layer_test.go b/server/channels/store/localcachelayer/session_attribute_layer_test.go new file mode 100644 index 00000000000..ed379fad320 --- /dev/null +++ b/server/channels/store/localcachelayer/session_attribute_layer_test.go @@ -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) + }) +} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 23c5c02b429..78c43d7e765 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -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} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 0010f1d3a2b..447dfc686d9 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -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{}) diff --git a/server/channels/store/sqlstore/session_attribute_store.go b/server/channels/store/sqlstore/session_attribute_store.go new file mode 100644 index 00000000000..2e85cc04bc5 --- /dev/null +++ b/server/channels/store/sqlstore/session_attribute_store.go @@ -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 +} diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index f252c26ff93..ac0683ddf2a 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -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 } diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 0fda03addb7..6b48690dfc6 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -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) diff --git a/server/channels/store/storetest/mocks/SessionAttributeStore.go b/server/channels/store/storetest/mocks/SessionAttributeStore.go new file mode 100644 index 00000000000..4d0a2cb90ce --- /dev/null +++ b/server/channels/store/storetest/mocks/SessionAttributeStore.go @@ -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 +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index 16ed34d912f..9b1c166746d 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -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() diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 999754b46b8..78ba6eb31eb 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -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, diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 202bc98a7ce..b3e8da36bfe 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -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} diff --git a/server/channels/web/handlers.go b/server/channels/web/handlers.go index 8a7f1723594..a775ad8f69d 100644 --- a/server/channels/web/handlers.go +++ b/server/channels/web/handlers.go @@ -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) } diff --git a/server/enterprise/metrics/metrics.go b/server/enterprise/metrics/metrics.go index 9286a4520e1..d5be5ea08c6 100644 --- a/server/enterprise/metrics/metrics.go +++ b/server/enterprise/metrics/metrics.go @@ -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, diff --git a/server/i18n/en.json b/server/i18n/en.json index 60c47b72a47..7abd2094624 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/access_policy.go b/server/public/model/access_policy.go index b0e5d618f78..2ca92fdda87 100644 --- a/server/public/model/access_policy.go +++ b/server/public/model/access_policy.go @@ -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 diff --git a/server/public/model/access_policy_test.go b/server/public/model/access_policy_test.go index e9208ee206c..cdd7c7ff37f 100644 --- a/server/public/model/access_policy_test.go +++ b/server/public/model/access_policy_test.go @@ -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) { diff --git a/server/public/model/cluster_message.go b/server/public/model/cluster_message.go index ffc4264c0aa..16439303fce 100644 --- a/server/public/model/cluster_message.go +++ b/server/public/model/cluster_message.go @@ -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" diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index b01067aab02..a47e82ec2ed 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -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 diff --git a/server/public/model/session.go b/server/public/model/session.go index fa067253005..706b4a4cc1c 100644 --- a/server/public/model/session.go +++ b/server/public/model/session.go @@ -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 diff --git a/webapp/channels/src/components/admin_console/permission_policies/policy_details/index.ts b/webapp/channels/src/components/admin_console/permission_policies/policy_details/index.ts index aaf7bdab794..13f6686e560 100644 --- a/webapp/channels/src/components/admin_console/permission_policies/policy_details/index.ts +++ b/webapp/channels/src/components/admin_console/permission_policies/policy_details/index.ts @@ -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', }; } diff --git a/webapp/channels/src/components/admin_console/permission_policies/policy_details/permission_policy_details.tsx b/webapp/channels/src/components/admin_console/permission_policies/policy_details/permission_policy_details.tsx index 077007d1965..9e0ead8b2d5 100644 --- a/webapp/channels/src/components/admin_console/permission_policies/policy_details/permission_policy_details.tsx +++ b/webapp/channels/src/components/admin_console/permission_policies/policy_details/permission_policy_details.tsx @@ -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)); diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 8c29ab2a7bc..41016b5244b 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -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", diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index ed5cfadcaa8..15c8f745d88 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -135,6 +135,7 @@ export type ClientConfig = { FeatureFlagContentFlagging: string; FeatureFlagClassificationMarkings: string; FeatureFlagManagedChannelCategories: string; + FeatureFlagSessionAttributes: string; ForgotPasswordLink: string; GiphySdkKey: string;