diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 215fd5497ce..5f973d118e1 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -557,7 +557,7 @@ summary: Test the configured file storage backend description: > Send a test to validate that the server can connect to the configured - file storage backend (Amazon S3 or Azure Blob Storage). Optionally + file storage backend (local, Amazon S3, or Azure Blob Storage). Optionally provide a configuration in the request body to test. If no valid configuration is present in the request body the current server configuration will be tested. diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/azure_blob_storage_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/azure_blob_storage_spec.js index 6c1fc7ab4df..187582345a1 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/azure_blob_storage_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/azure_blob_storage_spec.js @@ -28,7 +28,7 @@ describe('Environment - File Storage (Azure Blob Storage)', () => { should('have.text', 'Azure Blob Storage'); }); - it('enables Azure-only fields and disables S3-only fields when Azure is selected', () => { + it('enables Azure-only fields and hides S3-only fields when Azure is selected', () => { // # Select the Azure driver cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob'); @@ -37,17 +37,44 @@ describe('Environment - File Storage (Azure Blob Storage)', () => { cy.findByTestId('FileSettings.AzureContainerinput').should('not.be.disabled'); cy.findByTestId('FileSettings.AzurePathPrefixinput').should('not.be.disabled'); cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.be.disabled'); - cy.findByTestId('FileSettings.AzureEndpointinput').should('not.be.disabled'); + cy.findByTestId('FileSettings.AzureClouddropdown').should('not.be.disabled'); cy.findByTestId('FileSettings.AzureRequestTimeoutMillisecondsnumber').should('not.be.disabled'); - // * S3 fields are disabled when the driver is not S3 - cy.findByTestId('FileSettings.AmazonS3Bucketinput').should('be.disabled'); - cy.findByTestId('FileSettings.AmazonS3AccessKeyIdinput').should('be.disabled'); + // * The cloud dropdown exposes commercial / government / custom + cy.findByTestId('FileSettings.AzureClouddropdown').find('option[value="commercial"]').should('have.text', 'Azure Commercial'); + cy.findByTestId('FileSettings.AzureClouddropdown').find('option[value="government"]').should('have.text', 'Azure Government'); + cy.findByTestId('FileSettings.AzureClouddropdown').find('option[value="custom"]').should('have.text', 'Custom Endpoint'); - // * Local directory is also disabled + // * S3 fields are not rendered when the driver is not S3 + cy.findByTestId('FileSettings.AmazonS3Bucketinput').should('not.exist'); + cy.findByTestId('FileSettings.AmazonS3AccessKeyIdinput').should('not.exist'); + + // * Local directory is disabled (still rendered) cy.findByTestId('FileSettings.Directoryinput').should('be.disabled'); }); + it('shows the custom endpoint only for the Custom cloud and the SSL toggle only for the other clouds', () => { + // # Select the Azure driver, then start on Commercial + cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob'); + cy.findByTestId('FileSettings.AzureClouddropdown').select('commercial'); + + // * Custom-only fields are hidden, SSL toggle is visible + cy.findByTestId('FileSettings.AzureEndpointinput').should('not.exist'); + cy.findByTestId('FileSettings.AzureSSLtrue').should('not.be.disabled'); + + // # Switch to Government + cy.findByTestId('FileSettings.AzureClouddropdown').select('government'); + cy.findByTestId('FileSettings.AzureEndpointinput').should('not.exist'); + cy.findByTestId('FileSettings.AzureSSLtrue').should('not.be.disabled'); + + // # Switch to Custom + cy.findByTestId('FileSettings.AzureClouddropdown').select('custom'); + + // * Custom endpoint becomes visible; SSL toggle goes away + cy.findByTestId('FileSettings.AzureEndpointinput').should('not.be.disabled'); + cy.findByTestId('FileSettings.AzureSSLtrue').should('not.exist'); + }); + it('hides Azure-only fields when the S3 driver is selected', () => { // # Select the S3 driver cy.findByTestId('FileSettings.DriverNamedropdown').select('amazons3'); @@ -56,6 +83,8 @@ describe('Environment - File Storage (Azure Blob Storage)', () => { cy.findByTestId('FileSettings.AzureStorageAccountinput').should('not.exist'); cy.findByTestId('FileSettings.AzureContainerinput').should('not.exist'); cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.exist'); + cy.findByTestId('FileSettings.AzureClouddropdown').should('not.exist'); + cy.findByTestId('FileSettings.AzureEndpointinput').should('not.exist'); }); it('exposes the backend-agnostic Test Connection button when Azure is selected', () => { 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/cmd/mattermost/commands/db.go b/server/cmd/mattermost/commands/db.go index f7eb04d8ba2..543a25f7b61 100644 --- a/server/cmd/mattermost/commands/db.go +++ b/server/cmd/mattermost/commands/db.go @@ -336,6 +336,7 @@ func ConfigToFileBackendSettings(s *model.FileSettings, enableComplianceFeature AzureAccessKey: *s.AzureAccessKey, AzureContainer: *s.AzureContainer, AzurePathPrefix: *s.AzurePathPrefix, + AzureCloud: *s.AzureCloud, AzureEndpoint: *s.AzureEndpoint, AzureSSL: s.AzureSSL == nil || *s.AzureSSL, AzureRequestTimeoutMilliseconds: *s.AzureRequestTimeoutMilliseconds, 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..3de05e2e6b5 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." @@ -11370,6 +11378,14 @@ "id": "model.config.is_valid.autotranslation.workers.app_error", "translation": "Workers must be between 1 and 64." }, + { + "id": "model.config.is_valid.azure_cloud.app_error", + "translation": "Invalid value {{.Value}} for {{.Setting}}. Must be 'commercial', 'government', or 'custom'." + }, + { + "id": "model.config.is_valid.azure_custom_endpoint.app_error", + "translation": "{{.Setting}} must be set when the corresponding AzureCloud value is 'custom'." + }, { "id": "model.config.is_valid.azure_timeout.app_error", "translation": "Invalid timeout value {{.Value}}. Should be a positive number." diff --git a/server/platform/shared/filestore/azurestore.go b/server/platform/shared/filestore/azurestore.go index 043de68624c..31671be2709 100644 --- a/server/platform/shared/filestore/azurestore.go +++ b/server/platform/shared/filestore/azurestore.go @@ -9,9 +9,11 @@ import ( "context" "crypto/tls" "encoding/base64" + "errors" "fmt" "io" "net/http" + "net/url" "path" "strings" "time" @@ -57,18 +59,9 @@ func NewAzureFileBackend(settings FileBackendSettings) (*AzureFileBackend, error scheme = "http" } - var serviceURL string - if settings.AzureEndpoint == "" { - // vhost-style production endpoint (Azure commercial cloud). - serviceURL = fmt.Sprintf("%s://%s.blob.core.windows.net/", scheme, settings.AzureStorageAccount) - } else { - // Path-style endpoint where the account is part of the URL path - // rather than the hostname. This covers Azurite and custom hosts - // (reverse proxies, gateways) that expose Azure Blob Storage - // without per-account DNS. Sovereign clouds (Azure Government, - // Azure China) use vhost-style URLs and are not supported via - // this setting; they require their own endpoint plumbing. - serviceURL = fmt.Sprintf("%s://%s/%s/", scheme, strings.Trim(settings.AzureEndpoint, "/"), settings.AzureStorageAccount) + serviceURL, err := buildAzureServiceURL(settings.AzureCloud, scheme, settings.AzureStorageAccount, settings.AzureEndpoint) + if err != nil { + return nil, err } var clientOptions *azblob.ClientOptions @@ -116,6 +109,53 @@ func (b *AzureFileBackend) DriverName() string { return driverAzure } +// buildAzureServiceURL renders the Blob service URL that the SDK signs +// requests against. The cloud value selects the topology: +// +// - commercial -> vhost-style against blob.core.windows.net, e.g. +// https://{account}.blob.core.windows.net/. +// - government -> vhost-style against blob.core.usgovcloudapi.net, +// e.g. https://{account}.blob.core.usgovcloudapi.net/. +// - custom -> the admin-provided endpoint is the full service URL, +// including scheme and storage account (vhost-style for production +// Azure, path-style for Azurite or reverse proxies). Mattermost +// does not modify the URL. +// +// Empty cloud is treated as commercial so existing configs that pre-date +// this field keep working. Shared-key auth signs against the URL host, +// so for custom deployments the admin is responsible for ensuring the +// host actually serves the storage account named in the credential. +func buildAzureServiceURL(cloud, scheme, account, endpoint string) (string, error) { + switch cloud { + case model.AzureCloudCommercial, "": + return fmt.Sprintf("%s://%s.blob.core.windows.net/", scheme, account), nil + case model.AzureCloudGovernment: + return fmt.Sprintf("%s://%s.blob.core.usgovcloudapi.net/", scheme, account), nil + case model.AzureCloudCustom: + if endpoint == "" { + return "", errors.New("AzureCloud=custom requires AzureEndpoint to be set") + } + // The admin owns this URL end to end, but we still reject inputs + // that the SDK is guaranteed to fail on later (missing scheme, + // missing host, a scheme other than http/https) so the + // failure mode is a clear configuration error at startup rather + // than an opaque SDK error on the first blob request. + parsed, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("AzureEndpoint is not a valid URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("AzureEndpoint must use http or https, got %q", endpoint) + } + if parsed.Host == "" { + return "", fmt.Errorf("AzureEndpoint must include a host, got %q", endpoint) + } + return endpoint, nil + default: + return "", fmt.Errorf("unknown AzureCloud value %q", cloud) + } +} + // prefix joins the configured pathPrefix and the caller-supplied path. // Using a plain path.Join, a value like "foo/../../secret" can escape // the prefix entirely, so we compute the join and verify the result is diff --git a/server/platform/shared/filestore/azurestore_test.go b/server/platform/shared/filestore/azurestore_test.go index a6dea58aed5..10546fa7155 100644 --- a/server/platform/shared/filestore/azurestore_test.go +++ b/server/platform/shared/filestore/azurestore_test.go @@ -7,14 +7,113 @@ import ( "bytes" "context" "errors" - "fmt" + "net" "os" "testing" + "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) +func TestBuildAzureServiceURL(t *testing.T) { + const account = "acmemattermost" + + tests := []struct { + name string + cloud string + scheme string + endpoint string + expected string + wantErr bool + }{ + { + name: "commercial cloud, default scheme", + cloud: model.AzureCloudCommercial, + scheme: "https", + expected: "https://acmemattermost.blob.core.windows.net/", + }, + { + name: "empty cloud falls back to commercial for legacy configs", + cloud: "", + scheme: "https", + expected: "https://acmemattermost.blob.core.windows.net/", + }, + { + name: "government cloud uses the usgovcloudapi suffix", + cloud: model.AzureCloudGovernment, + scheme: "https", + expected: "https://acmemattermost.blob.core.usgovcloudapi.net/", + }, + { + name: "custom cloud returns the endpoint verbatim (vhost-style)", + cloud: model.AzureCloudCustom, + scheme: "https", + endpoint: "https://acmemattermost.blob.core.windows.net/", + expected: "https://acmemattermost.blob.core.windows.net/", + }, + { + name: "custom cloud returns the endpoint verbatim (Azurite path-style)", + cloud: model.AzureCloudCustom, + scheme: "http", + endpoint: "http://localhost:10000/devstoreaccount1/", + expected: "http://localhost:10000/devstoreaccount1/", + }, + { + name: "custom cloud preserves arbitrary paths the admin provides", + cloud: model.AzureCloudCustom, + scheme: "https", + endpoint: "https://blob-proxy.internal.example.com/some/prefix/account/", + expected: "https://blob-proxy.internal.example.com/some/prefix/account/", + }, + { + name: "custom cloud rejects an empty endpoint", + cloud: model.AzureCloudCustom, + scheme: "https", + wantErr: true, + }, + { + name: "custom cloud rejects an endpoint missing the scheme", + cloud: model.AzureCloudCustom, + scheme: "https", + endpoint: "acmemattermost.blob.core.windows.net/", + wantErr: true, + }, + { + name: "custom cloud rejects an endpoint with no host", + cloud: model.AzureCloudCustom, + scheme: "https", + endpoint: "https:///acmemattermost/", + wantErr: true, + }, + { + name: "custom cloud rejects a non-HTTP scheme", + cloud: model.AzureCloudCustom, + scheme: "https", + endpoint: "ftp://blob.example.com/", + wantErr: true, + }, + { + name: "unknown cloud value is rejected", + cloud: "azuregermany", + scheme: "https", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildAzureServiceURL(tt.cloud, tt.scheme, account, tt.endpoint) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.expected, got) + }) + } +} + func TestAzureFileBackendPrefix(t *testing.T) { tests := []struct { name string @@ -126,8 +225,8 @@ func azuriteSettings(t *testing.T) FileBackendSettings { AzureStorageAccount: azuriteWellKnownAccount, AzureAccessKey: azuriteWellKnownKey, AzureContainer: "mattermost-test", - AzureEndpoint: fmt.Sprintf("%s:%s", host, port), - AzureSSL: false, + AzureCloud: model.AzureCloudCustom, + AzureEndpoint: "http://" + net.JoinHostPort(host, port) + "/" + azuriteWellKnownAccount + "/", AzureRequestTimeoutMilliseconds: 30000, } } diff --git a/server/platform/shared/filestore/filesstore.go b/server/platform/shared/filestore/filesstore.go index c7eacb1bb84..591dec8226a 100644 --- a/server/platform/shared/filestore/filesstore.go +++ b/server/platform/shared/filestore/filesstore.go @@ -70,6 +70,7 @@ type FileBackendSettings struct { AzureAccessKey string AzureContainer string AzurePathPrefix string + AzureCloud string AzureEndpoint string AzureSSL bool AzureRequestTimeoutMilliseconds int64 @@ -89,6 +90,7 @@ func NewFileBackendSettingsFromConfig(fileSettings *model.FileSettings, enableCo AzureAccessKey: *fileSettings.AzureAccessKey, AzureContainer: *fileSettings.AzureContainer, AzurePathPrefix: *fileSettings.AzurePathPrefix, + AzureCloud: *fileSettings.AzureCloud, AzureEndpoint: *fileSettings.AzureEndpoint, AzureSSL: fileSettings.AzureSSL == nil || *fileSettings.AzureSSL, AzureRequestTimeoutMilliseconds: *fileSettings.AzureRequestTimeoutMilliseconds, @@ -128,6 +130,7 @@ func NewExportFileBackendSettingsFromConfig(fileSettings *model.FileSettings, en AzureAccessKey: *fileSettings.ExportAzureAccessKey, AzureContainer: *fileSettings.ExportAzureContainer, AzurePathPrefix: *fileSettings.ExportAzurePathPrefix, + AzureCloud: *fileSettings.ExportAzureCloud, AzureEndpoint: *fileSettings.ExportAzureEndpoint, AzureSSL: fileSettings.ExportAzureSSL == nil || *fileSettings.ExportAzureSSL, AzureRequestTimeoutMilliseconds: *fileSettings.ExportAzureRequestTimeoutMilliseconds, 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/config.go b/server/public/model/config.go index d975f59c46b..a3c839832d6 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -38,6 +38,17 @@ const ( ImageDriverS3 = "amazons3" ImageDriverAzure = "azureblob" + // AzureCloudCommercial / AzureCloudGovernment select hardcoded Azure + // service endpoints so admins do not have to spell out the suffix + // for the well-known clouds. AzureCloudCustom hands control to the + // admin: FileSettings.AzureEndpoint becomes the full service URL, + // scheme and storage account included, and Mattermost passes it to + // the SDK unchanged. Use this for Azurite, reverse proxies, or any + // other non-default deployment topology. + AzureCloudCommercial = "commercial" + AzureCloudGovernment = "government" + AzureCloudCustom = "custom" + DatabaseDriverPostgres = "postgres" SearchengineElasticsearch = "elasticsearch" @@ -1806,6 +1817,7 @@ type FileSettings struct { AzureAccessKey *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none AzureContainer *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none AzurePathPrefix *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none + AzureCloud *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` AzureEndpoint *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none AzureSSL *bool `access:"environment_file_storage,write_restrictable,cloud_restrictable"` AzureRequestTimeoutMilliseconds *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none @@ -1831,6 +1843,7 @@ type FileSettings struct { ExportAzureAccessKey *string `access:"environment_file_storage,write_restrictable"` // telemetry: none ExportAzureContainer *string `access:"environment_file_storage,write_restrictable"` // telemetry: none ExportAzurePathPrefix *string `access:"environment_file_storage,write_restrictable"` // telemetry: none + ExportAzureCloud *string `access:"environment_file_storage,write_restrictable"` ExportAzureEndpoint *string `access:"environment_file_storage,write_restrictable"` // telemetry: none ExportAzureSSL *bool `access:"environment_file_storage,write_restrictable"` ExportAzureRequestTimeoutMilliseconds *int64 `access:"environment_file_storage,write_restrictable"` // telemetry: none @@ -1966,6 +1979,10 @@ func (s *FileSettings) SetDefaults(isUpdate bool) { s.AzurePathPrefix = NewPointer("") } + if s.AzureCloud == nil { + s.AzureCloud = NewPointer(AzureCloudCommercial) + } + if s.AzureEndpoint == nil { s.AzureEndpoint = NewPointer("") } @@ -2064,6 +2081,10 @@ func (s *FileSettings) SetDefaults(isUpdate bool) { s.ExportAzurePathPrefix = NewPointer("") } + if s.ExportAzureCloud == nil { + s.ExportAzureCloud = NewPointer(AzureCloudCommercial) + } + if s.ExportAzureEndpoint == nil { s.ExportAzureEndpoint = NewPointer("") } @@ -4563,6 +4584,16 @@ func (s *FileSettings) isValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.azure_timeout.app_error", map[string]any{"Value": *s.AzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest) } + switch *s.AzureCloud { + case AzureCloudCommercial, AzureCloudGovernment, AzureCloudCustom: + default: + return NewAppError("Config.IsValid", "model.config.is_valid.azure_cloud.app_error", map[string]any{"Setting": "FileSettings.AzureCloud", "Value": *s.AzureCloud}, "", http.StatusBadRequest) + } + + if *s.AzureCloud == AzureCloudCustom && *s.AzureEndpoint == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.azure_custom_endpoint.app_error", map[string]any{"Setting": "FileSettings.AzureEndpoint"}, "", http.StatusBadRequest) + } + if *s.AmazonS3StorageClass != "" && !slices.Contains([]string{StorageClassStandard, StorageClassReducedRedundancy, StorageClassStandardIA, StorageClassOnezoneIA, StorageClassIntelligentTiering, StorageClassGlacier, StorageClassDeepArchive, StorageClassOutposts, StorageClassGlacierIR, StorageClassSnow, StorageClassExpressOnezone}, *s.AmazonS3StorageClass) { return NewAppError("Config.IsValid", "model.config.is_valid.storage_class.app_error", map[string]any{"Value": *s.AmazonS3StorageClass}, "", http.StatusBadRequest) } @@ -4587,6 +4618,16 @@ func (s *FileSettings) isValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.export_azure_timeout.app_error", map[string]any{"Value": *s.ExportAzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest) } + switch *s.ExportAzureCloud { + case AzureCloudCommercial, AzureCloudGovernment, AzureCloudCustom: + default: + return NewAppError("Config.IsValid", "model.config.is_valid.azure_cloud.app_error", map[string]any{"Setting": "FileSettings.ExportAzureCloud", "Value": *s.ExportAzureCloud}, "", http.StatusBadRequest) + } + + if *s.ExportAzureCloud == AzureCloudCustom && *s.ExportAzureEndpoint == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.azure_custom_endpoint.app_error", map[string]any{"Setting": "FileSettings.ExportAzureEndpoint"}, "", http.StatusBadRequest) + } + if strings.TrimSpace(*s.ExportAmazonS3PathPrefix) != *s.ExportAmazonS3PathPrefix { return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.ExportAmazonS3PathPrefix", "Value": *s.ExportAmazonS3PathPrefix}, "", http.StatusBadRequest) } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index a0781e51a7d..40d2bf71189 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -322,6 +322,82 @@ func TestFileSettingsAzureRequestTimeoutBounds(t *testing.T) { } } +func TestFileSettingsAzureCloudValidation(t *testing.T) { + t.Run("unknown cloud values are rejected", func(t *testing.T) { + cases := []struct { + name string + configSetter func(*Config, *string) + errID string + }{ + {"AzureCloud", func(cfg *Config, v *string) { cfg.FileSettings.AzureCloud = v }, "model.config.is_valid.azure_cloud.app_error"}, + {"ExportAzureCloud", func(cfg *Config, v *string) { cfg.FileSettings.ExportAzureCloud = v }, "model.config.is_valid.azure_cloud.app_error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{} + cfg.SetDefaults() + tc.configSetter(cfg, NewPointer("not-a-real-cloud")) + + err := cfg.FileSettings.isValid() + require.NotNil(t, err) + assert.Equal(t, tc.errID, err.Id) + }) + } + }) + + t.Run("custom cloud requires endpoint", func(t *testing.T) { + cases := []struct { + name string + cloudSetter func(*Config, *string) + errID string + }{ + {"AzureCloud", func(cfg *Config, v *string) { cfg.FileSettings.AzureCloud = v }, "model.config.is_valid.azure_custom_endpoint.app_error"}, + {"ExportAzureCloud", func(cfg *Config, v *string) { cfg.FileSettings.ExportAzureCloud = v }, "model.config.is_valid.azure_custom_endpoint.app_error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{} + cfg.SetDefaults() + tc.cloudSetter(cfg, NewPointer(AzureCloudCustom)) + + err := cfg.FileSettings.isValid() + require.NotNil(t, err) + assert.Equal(t, tc.errID, err.Id) + }) + } + }) + + t.Run("custom cloud with a valid endpoint passes validation", func(t *testing.T) { + cases := []struct { + name string + cloudSetter func(*Config, *string) + endpointSetter func(*Config, *string) + }{ + { + "AzureCloud", + func(cfg *Config, v *string) { cfg.FileSettings.AzureCloud = v }, + func(cfg *Config, v *string) { cfg.FileSettings.AzureEndpoint = v }, + }, + { + "ExportAzureCloud", + func(cfg *Config, v *string) { cfg.FileSettings.ExportAzureCloud = v }, + func(cfg *Config, v *string) { cfg.FileSettings.ExportAzureEndpoint = v }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{} + cfg.SetDefaults() + tc.cloudSetter(cfg, NewPointer(AzureCloudCustom)) + tc.endpointSetter(cfg, NewPointer("https://account.blob.core.windows.net/")) + + err := cfg.FileSettings.isValid() + require.Nil(t, err) + }) + } + }) +} + func TestFileSettingsAzurePathPrefixTraversal(t *testing.T) { cases := []struct { name string 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/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index a58a1bebe53..ede79f029f0 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -139,6 +139,9 @@ export {it}; const FILE_STORAGE_DRIVER_LOCAL = 'local'; const FILE_STORAGE_DRIVER_S3 = 'amazons3'; const FILE_STORAGE_DRIVER_AZURE = 'azureblob'; +const AZURE_CLOUD_COMMERCIAL = 'commercial'; +const AZURE_CLOUD_GOVERNMENT = 'government'; +const AZURE_CLOUD_CUSTOM = 'custom'; const MEBIBYTE = Math.pow(1024, 2); const SAML_SETTINGS_SIGNATURE_ALGORITHM_SHA1 = 'RSAwithSHA1'; @@ -1220,6 +1223,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1231,6 +1235,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1242,6 +1247,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1264,6 +1270,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1275,6 +1282,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1286,6 +1294,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'bool', @@ -1296,6 +1305,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'bool', @@ -1313,7 +1323,10 @@ const AdminDefinition: AdminDefinitionType = { ), }, help_text_markdown: false, - isHidden: it.not(it.licensedForFeature('Compliance')), + isHidden: it.any( + it.not(it.licensedForFeature('Compliance')), + it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), + ), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), @@ -1328,6 +1341,7 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), }, { type: 'text', @@ -1339,6 +1353,32 @@ const AdminDefinition: AdminDefinitionType = { it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), + }, + { + type: 'dropdown', + key: 'FileSettings.AzureCloud', + label: defineMessage({id: 'admin.image.azureCloudTitle', defaultMessage: 'Azure Cloud:'}), + help_text: defineMessage({id: 'admin.image.azureCloudDescription', defaultMessage: 'The Azure cloud to connect to. Choose "Azure Commercial" or "Azure Government" to use the well-known endpoint for that cloud; only the storage account name is required. Choose "Custom Endpoint" to point at an arbitrary host such as Azurite, a reverse proxy, or any other Azure cloud (for example Azure China).'}), + options: [ + { + value: AZURE_CLOUD_COMMERCIAL, + display_name: defineMessage({id: 'admin.image.azureCloudCommercial', defaultMessage: 'Azure Commercial'}), + }, + { + value: AZURE_CLOUD_GOVERNMENT, + display_name: defineMessage({id: 'admin.image.azureCloudGovernment', defaultMessage: 'Azure Government'}), + }, + { + value: AZURE_CLOUD_CUSTOM, + display_name: defineMessage({id: 'admin.image.azureCloudCustom', defaultMessage: 'Custom Endpoint'}), + }, + ], + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), + it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), + ), + isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), }, { type: 'text', @@ -1391,25 +1431,33 @@ const AdminDefinition: AdminDefinitionType = { { type: 'text', key: 'FileSettings.AzureEndpoint', - label: defineMessage({id: 'admin.image.azureEndpointTitle', defaultMessage: 'Azure Endpoint:'}), - help_text: defineMessage({id: 'admin.image.azureEndpointDescription', defaultMessage: 'Optional host[:port] override for non-default endpoints such as Azurite, Azure Government, or other sovereign clouds. Leave empty to use the default "\'{account}\'.blob.core.windows.net" host.'}), - placeholder: defineMessage({id: 'admin.image.azureEndpointExample', defaultMessage: 'E.g.: "azurite:10000" or leave empty'}), + label: defineMessage({id: 'admin.image.azureEndpointTitle', defaultMessage: 'Custom Azure Endpoint:'}), + help_text: defineMessage({id: 'admin.image.azureEndpointDescription', defaultMessage: 'Full Blob service URL, including scheme and storage account. Mattermost does not modify this URL, so the storage account must already be embedded in the hostname (vhost-style, e.g. "https://acmemattermost.blob.core.chinacloudapi.cn/") or in the path (path-style, e.g. "http://localhost:10000/devstoreaccount1/"). Shared-key auth signs against the host this URL points at, so make sure it actually serves the storage account named above.'}), + placeholder: defineMessage({id: 'admin.image.azureEndpointExample', defaultMessage: 'E.g.: "http://localhost:10000/devstoreaccount1/"'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), + it.not(it.stateEquals('FileSettings.AzureCloud', AZURE_CLOUD_CUSTOM)), + ), + isHidden: it.any( + it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), + it.not(it.stateEquals('FileSettings.AzureCloud', AZURE_CLOUD_CUSTOM)), ), - isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), }, { type: 'bool', key: 'FileSettings.AzureSSL', label: defineMessage({id: 'admin.image.azureSSLTitle', defaultMessage: 'Enable Secure Azure Blob Storage Connections:'}), - help_text: defineMessage({id: 'admin.image.azureSSLDescription', defaultMessage: 'When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only.'}), + help_text: defineMessage({id: 'admin.image.azureSSLDescription', defaultMessage: 'When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only. Ignored for the Custom Endpoint cloud (the scheme is part of the endpoint URL).'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), + it.stateEquals('FileSettings.AzureCloud', AZURE_CLOUD_CUSTOM), + ), + isHidden: it.any( + it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), + it.stateEquals('FileSettings.AzureCloud', AZURE_CLOUD_CUSTOM), ), - isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)), }, { type: 'number', @@ -1626,6 +1674,31 @@ const AdminDefinition: AdminDefinitionType = { ), isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_S3)), it.stateEquals('FileSettings.DedicatedExportStore', false)), }, + { + type: 'dropdown', + key: 'FileSettings.ExportAzureCloud', + label: defineMessage({id: 'admin.image.azureCloudTitle', defaultMessage: 'Azure Cloud:'}), + help_text: defineMessage({id: 'admin.image.azureCloudDescription', defaultMessage: 'The Azure cloud to connect to. Choose "Azure Commercial" or "Azure Government" to use the well-known endpoint for that cloud; only the storage account name is required. Choose "Custom Endpoint" to point at an arbitrary host such as Azurite, a reverse proxy, or any other Azure cloud (for example Azure China).'}), + options: [ + { + value: AZURE_CLOUD_COMMERCIAL, + display_name: defineMessage({id: 'admin.image.azureCloudCommercial', defaultMessage: 'Azure Commercial'}), + }, + { + value: AZURE_CLOUD_GOVERNMENT, + display_name: defineMessage({id: 'admin.image.azureCloudGovernment', defaultMessage: 'Azure Government'}), + }, + { + value: AZURE_CLOUD_CUSTOM, + display_name: defineMessage({id: 'admin.image.azureCloudCustom', defaultMessage: 'Custom Endpoint'}), + }, + ], + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), + it.stateEquals('FileSettings.DedicatedExportStore', false), + ), + isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)), + }, { type: 'text', key: 'FileSettings.ExportAzureStorageAccount', @@ -1677,25 +1750,35 @@ const AdminDefinition: AdminDefinitionType = { { type: 'text', key: 'FileSettings.ExportAzureEndpoint', - label: defineMessage({id: 'admin.image.azureEndpointTitle', defaultMessage: 'Azure Endpoint:'}), - help_text: defineMessage({id: 'admin.image.azureEndpointDescription', defaultMessage: 'Optional host[:port] override for non-default endpoints such as Azurite, Azure Government, or other sovereign clouds. Leave empty to use the default "\'{account}\'.blob.core.windows.net" host.'}), - placeholder: defineMessage({id: 'admin.image.azureEndpointExample', defaultMessage: 'E.g.: "azurite:10000" or leave empty'}), + label: defineMessage({id: 'admin.image.azureEndpointTitle', defaultMessage: 'Custom Azure Endpoint:'}), + help_text: defineMessage({id: 'admin.image.azureEndpointDescription', defaultMessage: 'Full Blob service URL, including scheme and storage account. Mattermost does not modify this URL, so the storage account must already be embedded in the hostname (vhost-style, e.g. "https://acmemattermost.blob.core.chinacloudapi.cn/") or in the path (path-style, e.g. "http://localhost:10000/devstoreaccount1/"). Shared-key auth signs against the host this URL points at, so make sure it actually serves the storage account named above.'}), + placeholder: defineMessage({id: 'admin.image.azureEndpointExample', defaultMessage: 'E.g.: "http://localhost:10000/devstoreaccount1/"'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.stateEquals('FileSettings.DedicatedExportStore', false), + it.not(it.stateEquals('FileSettings.ExportAzureCloud', AZURE_CLOUD_CUSTOM)), + ), + isHidden: it.any( + it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), + it.stateEquals('FileSettings.DedicatedExportStore', false), + it.not(it.stateEquals('FileSettings.ExportAzureCloud', AZURE_CLOUD_CUSTOM)), ), - isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)), }, { type: 'bool', key: 'FileSettings.ExportAzureSSL', label: defineMessage({id: 'admin.image.azureSSLTitle', defaultMessage: 'Enable Secure Azure Blob Storage Connections:'}), - help_text: defineMessage({id: 'admin.image.azureSSLDescription', defaultMessage: 'When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only.'}), + help_text: defineMessage({id: 'admin.image.azureSSLDescription', defaultMessage: 'When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only. Ignored for the Custom Endpoint cloud (the scheme is part of the endpoint URL).'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.stateEquals('FileSettings.DedicatedExportStore', false), + it.stateEquals('FileSettings.ExportAzureCloud', AZURE_CLOUD_CUSTOM), + ), + isHidden: it.any( + it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), + it.stateEquals('FileSettings.DedicatedExportStore', false), + it.stateEquals('FileSettings.ExportAzureCloud', AZURE_CLOUD_CUSTOM), ), - isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)), }, { type: 'number', 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/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index fb31a5d674e..f13a38d9d5d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1539,21 +1539,26 @@ "admin.image.azureAccessKeyDescription": "The shared key for your Azure Storage account.", "admin.image.azureAccessKeyExample": "E.g.: \"9MZbtYgfq18PJ8PbRaJ5u91IH8izHvReTbcuQzMl+So=\"", "admin.image.azureAccessKeyTitle": "Azure Storage Account Key:", + "admin.image.azureCloudCommercial": "Azure Commercial", + "admin.image.azureCloudCustom": "Custom Endpoint", + "admin.image.azureCloudDescription": "The Azure cloud to connect to. Choose \"Azure Commercial\" or \"Azure Government\" to use the well-known endpoint for that cloud; only the storage account name is required. Choose \"Custom Endpoint\" to point at an arbitrary host such as Azurite, a reverse proxy, or any other Azure cloud (for example Azure China).", + "admin.image.azureCloudGovernment": "Azure Government", + "admin.image.azureCloudTitle": "Azure Cloud:", "admin.image.azureContainerDescription": "Name of the container in your Azure Storage account.", "admin.image.azureContainerExample": "E.g.: \"mattermost-media\"", "admin.image.azureContainerExportDescription": "Name of the container in your Azure Storage account.", "admin.image.azureContainerExportExample": "E.g.: \"mattermost-export\"", "admin.image.azureContainerTitle": "Azure Container:", - "admin.image.azureEndpointDescription": "Optional host[:port] override for non-default endpoints such as Azurite, Azure Government, or other sovereign clouds. Leave empty to use the default \"'{account}'.blob.core.windows.net\" host.", - "admin.image.azureEndpointExample": "E.g.: \"azurite:10000\" or leave empty", - "admin.image.azureEndpointTitle": "Azure Endpoint:", + "admin.image.azureEndpointDescription": "Full Blob service URL, including scheme and storage account. Mattermost does not modify this URL, so the storage account must already be embedded in the hostname (vhost-style, e.g. \"https://acmemattermost.blob.core.chinacloudapi.cn/\") or in the path (path-style, e.g. \"http://localhost:10000/devstoreaccount1/\"). Shared-key auth signs against the host this URL points at, so make sure it actually serves the storage account named above.", + "admin.image.azureEndpointExample": "E.g.: \"http://localhost:10000/devstoreaccount1/\"", + "admin.image.azureEndpointTitle": "Custom Azure Endpoint:", "admin.image.azurePathPrefixDescription": "Optional path prefix to use for blobs in your Azure container.", "admin.image.azurePathPrefixExample": "E.g.: \"files/\" or leave empty", "admin.image.azurePathPrefixTitle": "Azure Path Prefix:", "admin.image.azureRequestTimeoutDescription": "Number of milliseconds to wait for a response from Azure Blob Storage before timing out.", "admin.image.azureRequestTimeoutExample": "E.g.: \"30000\"", "admin.image.azureRequestTimeoutTitle": "Azure Request Timeout (Milliseconds):", - "admin.image.azureSSLDescription": "When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only.", + "admin.image.azureSSLDescription": "When false, allow insecure connections to Azure Blob Storage. Defaults to secure connections only. Ignored for the Custom Endpoint cloud (the scheme is part of the endpoint URL).", "admin.image.azureSSLTitle": "Enable Secure Azure Blob Storage Connections:", "admin.image.azureStorageAccountDescription": "The name of your Azure Storage account.", "admin.image.azureStorageAccountExample": "E.g.: \"mattermoststorage\"", 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;