mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Merge branch 'master' into MM-67412-system-console-attribute-management
This commit is contained in:
commit
503eb74fef
40 changed files with 1322 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
78
server/channels/app/session_attributes.go
Normal file
78
server/channels/app/session_attributes.go
Normal 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 ""
|
||||
}
|
||||
224
server/channels/app/session_attributes_test.go
Normal file
224
server/channels/app/session_attributes_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
28
server/channels/store/sqlstore/session_attribute_store.go
Normal file
28
server/channels/store/sqlstore/session_attribute_store.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export type ClientConfig = {
|
|||
FeatureFlagContentFlagging: string;
|
||||
FeatureFlagClassificationMarkings: string;
|
||||
FeatureFlagManagedChannelCategories: string;
|
||||
FeatureFlagSessionAttributes: string;
|
||||
|
||||
ForgotPasswordLink: string;
|
||||
GiphySdkKey: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue