Merge branch 'master' into MM-67412-system-console-attribute-management

This commit is contained in:
Mattermost Build 2026-05-27 01:25:57 +02:00 committed by GitHub
commit 503eb74fef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1322 additions and 53 deletions

View file

@ -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.

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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

View file

@ -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,
}
}

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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)
}

View file

@ -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

View file

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

View file

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

View file

@ -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',

View file

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

View file

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

View file

@ -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\"",

View file

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