diff --git a/Makefile b/Makefile index fb43adc3ca..fb6c409add 100644 --- a/Makefile +++ b/Makefile @@ -315,6 +315,10 @@ sharedchannel-mocks: ## Creates mock files for shared channels. $(GOBIN)/mockery -dir=./services/sharedchannel -name=ServerIface -output=./services/sharedchannel -inpkg -outpkg=sharedchannel -testonly -note 'Regenerate this file using `make sharedchannel-mocks`.' $(GOBIN)/mockery -dir=./services/sharedchannel -name=AppIface -output=./services/sharedchannel -inpkg -outpkg=sharedchannel -testonly -note 'Regenerate this file using `make sharedchannel-mocks`.' +misc-mocks: ## Creates mocks for misc interfaces. + $(GO) get -modfile=go.tools.mod github.com/vektra/mockery/... + $(GOPATH)/bin/mockery -dir utils --name LicenseValidatorIface -output utils/mocks -note 'Regenerate this file using `make misc-mocks`.' + pluginapi: ## Generates api and hooks glue code for plugins $(GO) generate $(GOFLAGS) ./plugin diff --git a/api4/license.go b/api4/license.go index 3466ef4287..9476f60da1 100644 --- a/api4/license.go +++ b/api4/license.go @@ -11,12 +11,15 @@ import ( "io/ioutil" "net/http" + "github.com/mattermost/mattermost-server/v5/utils" + "github.com/mattermost/mattermost-server/v5/audit" "github.com/mattermost/mattermost-server/v5/model" ) func (api *API) InitLicense() { api.BaseRoutes.ApiRoot.Handle("/trial-license", api.ApiSessionRequired(requestTrialLicense)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/trial-license/prev", api.ApiSessionRequired(getPrevTrialLicense)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(addLicense)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(removeLicense)).Methods("DELETE") api.BaseRoutes.ApiRoot.Handle("/license/renewal", api.ApiSessionRequired(requestRenewalLink)).Methods("GET") @@ -94,7 +97,28 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { buf := bytes.NewBuffer(nil) io.Copy(buf, file) - license, appErr := c.App.Srv().SaveLicense(buf.Bytes()) + licenseBytes := buf.Bytes() + license, appErr := utils.LicenseValidator.LicenseFromBytes(licenseBytes) + if appErr != nil { + c.Err = appErr + return + } + + // skip the restrictions if license is a sanctioned trial + if !license.IsSanctionedTrial() && license.IsTrialLicense() { + canStartTrialLicense, err := c.App.Srv().LicenseManager.CanStartTrial() + if err != nil { + c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusInternalServerError) + return + } + + if !canStartTrialLicense { + c.Err = model.NewAppError("addLicense", "api.license.request-trial.can-start-trial.not-allowed", nil, "", http.StatusBadRequest) + return + } + } + + license, appErr = c.App.Srv().SaveLicense(licenseBytes) if appErr != nil { if appErr.Id == model.EXPIRED_LICENSE_ERROR { c.LogAudit("failed - expired or non-started license") @@ -154,6 +178,17 @@ func requestTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) { return } + canStartTrialLicense, err := c.App.Srv().LicenseManager.CanStartTrial() + if err != nil { + c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.can-start-trial.error", nil, err.Error(), http.StatusInternalServerError) + return + } + + if !canStartTrialLicense { + c.Err = model.NewAppError("requestTrialLicense", "api.license.request-trial.can-start-trial.not-allowed", nil, "", http.StatusBadRequest) + return + } + var trialRequest struct { Users int `json:"users"` TermsAccepted bool `json:"terms_accepted"` @@ -175,9 +210,9 @@ func requestTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) { return } - currentUser, err := c.App.GetUser(c.AppContext.Session().UserId) - if err != nil { - c.Err = err + currentUser, appErr := c.App.GetUser(c.AppContext.Session().UserId) + if appErr != nil { + c.Err = appErr return } @@ -238,3 +273,21 @@ func requestRenewalLink(c *Context, w http.ResponseWriter, r *http.Request) { return } } + +func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) { + license, err := c.App.Srv().LicenseManager.GetPrevTrial() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var clientLicense map[string]string + + if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PERMISSION_READ_LICENSE_INFORMATION) { + clientLicense = utils.GetClientLicense(license) + } else { + clientLicense = utils.GetSanitizedClientLicense(utils.GetClientLicense(license)) + } + + w.Write([]byte(model.MapToJson(clientLicense))) +} diff --git a/api4/license_test.go b/api4/license_test.go index a3955b065d..4708f90d9d 100644 --- a/api4/license_test.go +++ b/api4/license_test.go @@ -4,9 +4,16 @@ package api4 import ( + "encoding/json" "net/http" "testing" + "time" + "github.com/mattermost/mattermost-server/v5/einterfaces/mocks" + "github.com/mattermost/mattermost-server/v5/utils" + mocks2 "github.com/mattermost/mattermost-server/v5/utils/mocks" + "github.com/mattermost/mattermost-server/v5/utils/testutils" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v5/model" @@ -75,6 +82,85 @@ func TestUploadLicenseFile(t *testing.T) { CheckBadRequestStatus(t, resp) require.False(t, ok) }) + + t.Run("server has already gone through trial", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = false }) + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + //startTimestamp, err := time.Parse("2 Jan 2006 3:04 pm", "1 Jan 2021 12:00 am") + //require.Nil(t, err) + + userCount := 100 + mills := model.GetMillis() + + license := model.License{ + Id: "AAAAAAAAAAAAAAAAAAAAAAAAAA", + Features: &model.Features{ + Users: &userCount, + }, + Customer: &model.Customer{ + Name: "Test", + }, + StartsAt: mills + 100, + ExpiresAt: mills + 100 + (30*(time.Hour*24) + (time.Hour * 8)).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + licenseBytes, _ := json.Marshal(license) + licenseStr := string(licenseBytes) + + mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(true, licenseStr) + utils.LicenseValidator = &mockLicenseValidator + + licenseManagerMock := &mocks.LicenseInterface{} + licenseManagerMock.On("CanStartTrial").Return(false, nil).Once() + th.App.Srv().LicenseManager = licenseManagerMock + + ok, resp := th.SystemAdminClient.UploadLicenseFile([]byte("sadasdasdasdasdasdsa")) + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Equal(t, "api.license.request-trial.can-start-trial.not-allowed", resp.Error.Id) + }) + + t.Run("allow uploading sanctioned trials even if server already gone through trial", func(t *testing.T) { + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + userCount := 100 + mills := model.GetMillis() + + license := model.License{ + Id: "PPPPPPPPPPPPPPPPPPPPPPPPPP", + Features: &model.Features{ + Users: &userCount, + }, + Customer: &model.Customer{ + Name: "Test", + }, + IsTrial: true, + StartsAt: mills + 100, + ExpiresAt: mills + 100 + (29*(time.Hour*24) + (time.Hour * 8)).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + + licenseBytes, _ := json.Marshal(license) + licenseStr := string(licenseBytes) + + mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(true, licenseStr) + + utils.LicenseValidator = &mockLicenseValidator + + licenseManagerMock := &mocks.LicenseInterface{} + licenseManagerMock.On("CanStartTrial").Return(false, nil).Once() + th.App.Srv().LicenseManager = licenseManagerMock + + ok, resp := th.SystemAdminClient.UploadLicenseFile([]byte("sadasdasdasdasdasdsa")) + require.False(t, ok) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Nil(t, resp.Error) + }) } func TestRemoveLicenseFile(t *testing.T) { @@ -116,6 +202,10 @@ func TestRequestTrialLicense(t *testing.T) { th := Setup(t) defer th.TearDown() + licenseManagerMock := &mocks.LicenseInterface{} + licenseManagerMock.On("CanStartTrial").Return(true, nil) + th.App.Srv().LicenseManager = licenseManagerMock + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065/" }) t.Run("permission denied", func(t *testing.T) { diff --git a/app/enterprise.go b/app/enterprise.go index a98d0737d9..38de7fbddb 100644 --- a/app/enterprise.go +++ b/app/enterprise.go @@ -181,6 +181,12 @@ func RegisterNotificationInterface(f func(*Server) einterfaces.NotificationInter notificationInterface = f } +var licenseInterface func(*Server) einterfaces.LicenseInterface + +func RegisterLicenseInterface(f func(*Server) einterfaces.LicenseInterface) { + licenseInterface = f +} + func (s *Server) initEnterprise() { if metricsInterface != nil { s.Metrics = metricsInterface(s) @@ -200,6 +206,11 @@ func (s *Server) initEnterprise() { if elasticsearchInterface != nil { s.SearchEngine.RegisterElasticsearchEngine(elasticsearchInterface(s)) } + + if licenseInterface != nil { + s.LicenseManager = licenseInterface(s) + } + if accountMigrationInterface != nil { s.AccountMigration = accountMigrationInterface(s) } diff --git a/app/license.go b/app/license.go index 3619b201d8..f0b467c5c5 100644 --- a/app/license.go +++ b/app/license.go @@ -38,6 +38,26 @@ func (s *Server) LoadLicense() { // ENV var overrides all other sources of license. licenseStr := os.Getenv(LicenseEnv) if licenseStr != "" { + license, err := utils.LicenseValidator.LicenseFromBytes([]byte(licenseStr)) + if err != nil { + mlog.Error("Failed to read license set in environment.", mlog.Err(err)) + return + } + + // skip the restrictions if license is a sanctioned trial + if !license.IsSanctionedTrial() && license.IsTrialLicense() { + canStartTrialLicense, err := s.LicenseManager.CanStartTrial() + if err != nil { + mlog.Info("Failed to validate trial eligibility.", mlog.Err(err)) + return + } + + if !canStartTrialLicense { + mlog.Info("Cannot start trial multiple times.") + return + } + } + if s.ValidateAndSetLicenseBytes([]byte(licenseStr)) { mlog.Info("License key from ENV is valid, unlocking enterprise features.") } @@ -75,7 +95,7 @@ func (s *Server) LoadLicense() { } func (s *Server) SaveLicense(licenseBytes []byte) (*model.License, *model.AppError) { - success, licenseStr := utils.ValidateLicense(licenseBytes) + success, licenseStr := utils.LicenseValidator.ValidateLicense(licenseBytes) if !success { return nil, model.NewAppError("addLicense", model.INVALID_LICENSE_ERROR, nil, "", http.StatusBadRequest) } @@ -174,7 +194,7 @@ func (s *Server) SetLicense(license *model.License) bool { } func (s *Server) ValidateAndSetLicenseBytes(b []byte) bool { - if success, licenseStr := utils.ValidateLicense(b); success { + if success, licenseStr := utils.LicenseValidator.ValidateLicense(b); success { license := model.LicenseFromJson(strings.NewReader(licenseStr)) s.SetLicense(license) return true @@ -228,22 +248,7 @@ func (s *Server) RemoveLicenseListener(id string) { } func (s *Server) GetSanitizedClientLicense() map[string]string { - sanitizedLicense := make(map[string]string) - - for k, v := range s.ClientLicense() { - sanitizedLicense[k] = v - } - - delete(sanitizedLicense, "Id") - delete(sanitizedLicense, "Name") - delete(sanitizedLicense, "Email") - delete(sanitizedLicense, "IssuedAt") - delete(sanitizedLicense, "StartsAt") - delete(sanitizedLicense, "ExpiresAt") - delete(sanitizedLicense, "SkuName") - delete(sanitizedLicense, "SkuShortName") - - return sanitizedLicense + return utils.GetSanitizedClientLicense(s.ClientLicense()) } // RequestTrialLicense request a trial license from the mattermost official license server diff --git a/app/server.go b/app/server.go index ad43c7b323..bed0a4c524 100644 --- a/app/server.go +++ b/app/server.go @@ -191,6 +191,7 @@ type Server struct { Metrics einterfaces.MetricsInterface Notification einterfaces.NotificationInterface Saml einterfaces.SamlInterface + LicenseManager einterfaces.LicenseInterface CacheProvider cache.Provider diff --git a/einterfaces/license.go b/einterfaces/license.go new file mode 100644 index 0000000000..036742ca36 --- /dev/null +++ b/einterfaces/license.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import "github.com/mattermost/mattermost-server/v5/model" + +type LicenseInterface interface { + CanStartTrial() (bool, error) + GetPrevTrial() (*model.License, error) +} diff --git a/einterfaces/mocks/LicenseInterface.go b/einterfaces/mocks/LicenseInterface.go new file mode 100644 index 0000000000..a208271048 --- /dev/null +++ b/einterfaces/mocks/LicenseInterface.go @@ -0,0 +1,59 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v5/model" + mock "github.com/stretchr/testify/mock" +) + +// LicenseInterface is an autogenerated mock type for the LicenseInterface type +type LicenseInterface struct { + mock.Mock +} + +// CanStartTrial provides a mock function with given fields: +func (_m *LicenseInterface) CanStartTrial() (bool, error) { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPrevTrial provides a mock function with given fields: +func (_m *LicenseInterface) GetPrevTrial() (*model.License, error) { + ret := _m.Called() + + var r0 *model.License + if rf, ok := ret.Get(0).(func() *model.License); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.License) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/i18n/en.json b/i18n/en.json index 4c0371c82b..c43c15c776 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1976,6 +1976,14 @@ "id": "api.license.request-trial.bad-request.terms-not-accepted", "translation": "You must accept the Mattermost Software Evaluation Agreement and Privacy Policy to request a license." }, + { + "id": "api.license.request-trial.can-start-trial.error", + "translation": "Could not check if a trial can be started" + }, + { + "id": "api.license.request-trial.can-start-trial.not-allowed", + "translation": "This trial license key for Mattermost Enterprise Edition has expired and is no longer valid. If you would like to extend your trial period please [contact our sales team](https://mattermost.com/contact-us/)." + }, { "id": "api.license.request_renewal_link.app_error", "translation": "Error getting the license renewal link" diff --git a/model/license.go b/model/license.go index 6823b5ffdd..83b1e07f92 100644 --- a/model/license.go +++ b/model/license.go @@ -7,6 +7,7 @@ import ( "encoding/json" "io" "net/http" + "time" ) const ( @@ -16,6 +17,16 @@ const ( LICENSE_RENEWAL_LINK = "https://mattermost.com/renew/" ) +var ( + trialDuration = 30*(time.Hour*24) + (time.Hour * 8) // 720 hours (30 days) + 8 hours is trial license duration + adminTrialDuration = 30*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 720 hours (30 days) + 23 hours, 59 mins and 59 seconds + + // a sanctioned trial's duration is either more than the upper bound, + // or less than the lower bound + sanctionedTrialDurationLowerBound = 31*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 744 hours (31 days) + 23 hours, 59 mins and 59 seconds + sanctionedTrialDurationUpperBound = 29*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 696 hours (29 days) + 23 hours, 59 mins and 59 seconds +) + type LicenseRecord struct { Id string `json:"id"` CreateAt int64 `json:"create_at"` @@ -263,6 +274,17 @@ func (l *License) ToJson() string { return string(b) } +func (l *License) IsTrialLicense() bool { + return l.IsTrial || (l.ExpiresAt-l.StartsAt) == trialDuration.Milliseconds() || (l.ExpiresAt-l.StartsAt) == adminTrialDuration.Milliseconds() +} + +func (l *License) IsSanctionedTrial() bool { + duration := l.ExpiresAt - l.StartsAt + + return l.IsTrialLicense() && + (duration >= sanctionedTrialDurationLowerBound.Milliseconds() || duration <= sanctionedTrialDurationUpperBound.Milliseconds()) +} + // NewTestLicense returns a license that expires in the future and has the given features. func NewTestLicense(features ...string) *License { ret := &License{ diff --git a/model/license_test.go b/model/license_test.go index c3d893d325..1a5d6d6c7a 100644 --- a/model/license_test.go +++ b/model/license_test.go @@ -6,6 +6,7 @@ package model import ( "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -240,3 +241,147 @@ func TestLicenseRecordPreSave(t *testing.T) { assert.NotZero(t, lr.CreateAt) } + +func TestLicense_IsTrialLicense(t *testing.T) { + t.Run("detect trial license directly from the flag", func(t *testing.T) { + license := &License{ + IsTrial: true, + } + assert.True(t, license.IsTrial) + + license.IsTrial = false + assert.False(t, license.IsTrialLicense()) + }) + + t.Run("detect trial license form duration", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC") + assert.NoError(t, err) + + license := &License{ + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + assert.True(t, license.IsTrialLicense()) + + endDate, err = time.Parse(time.RFC822, "01 Feb 21 08:00 UTC") + assert.NoError(t, err) + + license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond) + assert.False(t, license.IsTrialLicense()) + + // 30 days + 23 hours 59 mins 59 seconds + endDate, err = time.Parse("02 Jan 06 15:04:05 MST", "31 Jan 21 23:59:59 UTC") + assert.NoError(t, err) + license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond) + assert.True(t, license.IsTrialLicense()) + }) + + t.Run("detect trial with both flag and duration", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC") + assert.NoError(t, err) + + license := &License{ + IsTrial: true, + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + + assert.True(t, license.IsTrialLicense()) + license.IsTrial = false + + // detecting trial from duration + assert.True(t, license.IsTrialLicense()) + + endDate, _ = time.Parse(time.RFC822, "1 Feb 2021 08:00 UTC") + license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond) + assert.False(t, license.IsTrialLicense()) + + license.IsTrial = true + assert.True(t, license.IsTrialLicense()) + }) +} + +func TestLicense_IsSanctionedTrial(t *testing.T) { + t.Run("short duration sanctioned trial", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + endDate, err := time.Parse(time.RFC822, "08 Jan 21 08:00 UTC") + assert.NoError(t, err) + + license := &License{ + IsTrial: true, + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + + assert.True(t, license.IsSanctionedTrial()) + + license.IsTrial = false + assert.False(t, license.IsSanctionedTrial()) + }) + + t.Run("long duration sanctioned trial", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + endDate, err := time.Parse(time.RFC822, "02 Feb 21 08:00 UTC") + assert.NoError(t, err) + + license := &License{ + IsTrial: true, + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + + assert.True(t, license.IsSanctionedTrial()) + + license.IsTrial = false + assert.False(t, license.IsSanctionedTrial()) + }) + + t.Run("invalid duration for sanctioned trial", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + endDate, err := time.Parse(time.RFC822, "31 Jan 21 08:00 UTC") + assert.NoError(t, err) + + license := &License{ + IsTrial: true, + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + + assert.False(t, license.IsSanctionedTrial()) + }) + + t.Run("boundary conditions for sanctioned trial", func(t *testing.T) { + startDate, err := time.Parse(time.RFC822, "01 Jan 21 00:00 UTC") + assert.NoError(t, err) + + // 29 days + 23 hours 59 mins 59 seconds + endDate, err := time.Parse("02 Jan 06 15:04:05 MST", "30 Jan 21 23:59:59 UTC") + assert.NoError(t, err) + + license := &License{ + IsTrial: true, + StartsAt: startDate.UnixNano() / int64(time.Millisecond), + ExpiresAt: endDate.UnixNano() / int64(time.Millisecond), + } + + assert.True(t, license.IsSanctionedTrial()) + + // 31 days + 23 hours 59 mins 59 seconds + endDate, err = time.Parse("02 Jan 06 15:04:05 MST", "01 Feb 21 23:59:59 UTC") + assert.NoError(t, err) + license.ExpiresAt = endDate.UnixNano() / int64(time.Millisecond) + assert.True(t, license.IsSanctionedTrial()) + }) +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 8d1c74a64e..5648c37fcf 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -4393,6 +4393,24 @@ func (s *OpenTracingLayerLicenseStore) Get(id string) (*model.LicenseRecord, err return result, err } +func (s *OpenTracingLayerLicenseStore) GetAll() ([]*model.LicenseRecord, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "LicenseStore.GetAll") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.LicenseStore.GetAll() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerLicenseStore) Save(license *model.LicenseRecord) (*model.LicenseRecord, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "LicenseStore.Save") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index ca6c90a52b..e8cc96e90f 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -4746,6 +4746,26 @@ func (s *RetryLayerLicenseStore) Get(id string) (*model.LicenseRecord, error) { } +func (s *RetryLayerLicenseStore) GetAll() ([]*model.LicenseRecord, error) { + + tries := 0 + for { + result, err := s.LicenseStore.GetAll() + 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 + } + } + +} + func (s *RetryLayerLicenseStore) Save(license *model.LicenseRecord) (*model.LicenseRecord, error) { tries := 0 diff --git a/store/sqlstore/license_store.go b/store/sqlstore/license_store.go index e056009064..22ca5e117d 100644 --- a/store/sqlstore/license_store.go +++ b/store/sqlstore/license_store.go @@ -75,3 +75,21 @@ func (ls SqlLicenseStore) Get(id string) (*model.LicenseRecord, error) { } return obj.(*model.LicenseRecord), nil } + +func (ls SqlLicenseStore) GetAll() ([]*model.LicenseRecord, error) { + query := ls.getQueryBuilder(). + Select("*"). + From("Licenses") + + queryString, _, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "license_tosql") + } + + var licenses []*model.LicenseRecord + if _, err := ls.GetReplica().Select(&licenses, queryString); err != nil { + return nil, errors.Wrap(err, "failed to fetch licenses") + } + + return licenses, nil +} diff --git a/store/store.go b/store/store.go index 4ed413a4b1..adcd0605a6 100644 --- a/store/store.go +++ b/store/store.go @@ -578,6 +578,7 @@ type PreferenceStore interface { type LicenseStore interface { Save(license *model.LicenseRecord) (*model.LicenseRecord, error) Get(id string) (*model.LicenseRecord, error) + GetAll() ([]*model.LicenseRecord, error) } type TokenStore interface { diff --git a/store/storetest/mocks/LicenseStore.go b/store/storetest/mocks/LicenseStore.go index 6ffc0f3aaf..0bc01d907e 100644 --- a/store/storetest/mocks/LicenseStore.go +++ b/store/storetest/mocks/LicenseStore.go @@ -37,6 +37,29 @@ func (_m *LicenseStore) Get(id string) (*model.LicenseRecord, error) { return r0, r1 } +// GetAll provides a mock function with given fields: +func (_m *LicenseStore) GetAll() ([]*model.LicenseRecord, error) { + ret := _m.Called() + + var r0 []*model.LicenseRecord + if rf, ok := ret.Get(0).(func() []*model.LicenseRecord); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.LicenseRecord) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Save provides a mock function with given fields: license func (_m *LicenseStore) Save(license *model.LicenseRecord) (*model.LicenseRecord, error) { ret := _m.Called(license) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index ab666b5734..18d5f532fb 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -3995,6 +3995,22 @@ func (s *TimerLayerLicenseStore) Get(id string) (*model.LicenseRecord, error) { return result, err } +func (s *TimerLayerLicenseStore) GetAll() ([]*model.LicenseRecord, error) { + start := timemodule.Now() + + result, err := s.LicenseStore.GetAll() + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("LicenseStore.GetAll", success, elapsed) + } + return result, err +} + func (s *TimerLayerLicenseStore) Save(license *model.LicenseRecord) (*model.LicenseRecord, error) { start := timemodule.Now() diff --git a/utils/license.go b/utils/license.go index 3b851f8d1b..b10d1f31d0 100644 --- a/utils/license.go +++ b/utils/license.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/pem" "io/ioutil" + "net/http" "os" "path/filepath" "strconv" @@ -31,7 +32,33 @@ a0v85XL6i9ote2P+fLZ3wX9EoioHzgdgB7arOxY50QRJO7OyCqpKFKv6lRWTXuSt hwIDAQAB -----END PUBLIC KEY-----`) -func ValidateLicense(signed []byte) (bool, string) { +var LicenseValidator LicenseValidatorIface + +func init() { + if LicenseValidator == nil { + LicenseValidator = &LicenseValidatorImpl{} + } +} + +type LicenseValidatorIface interface { + LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) + ValidateLicense(signed []byte) (bool, string) +} + +type LicenseValidatorImpl struct { +} + +func (l *LicenseValidatorImpl) LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) { + success, licenseStr := l.ValidateLicense(licenseBytes) + if !success { + return nil, model.NewAppError("LicenseFromBytes", model.INVALID_LICENSE_ERROR, nil, "", http.StatusBadRequest) + } + + license := model.LicenseFromJson(strings.NewReader(licenseStr)) + return license, nil +} + +func (l *LicenseValidatorImpl) ValidateLicense(signed []byte) (bool, string) { decoded := make([]byte, base64.StdEncoding.DecodedLen(len(signed))) _, err := base64.StdEncoding.Decode(decoded, signed) @@ -87,7 +114,7 @@ func GetAndValidateLicenseFileFromDisk(location string) (*model.License, []byte) mlog.Info("License key has not been uploaded. Loading license key from disk at", mlog.String("filename", fileName)) licenseBytes := GetLicenseFileFromDisk(fileName) - success, licenseStr := ValidateLicense(licenseBytes) + success, licenseStr := LicenseValidator.ValidateLicense(licenseBytes) if !success { mlog.Error("Found license key at %v but it appears to be invalid.", mlog.String("filename", fileName)) return nil, nil @@ -161,7 +188,27 @@ func GetClientLicense(l *model.License) map[string]string { props["Cloud"] = strconv.FormatBool(*l.Features.Cloud) props["SharedChannels"] = strconv.FormatBool(*l.Features.SharedChannels) props["RemoteClusterService"] = strconv.FormatBool(*l.Features.RemoteClusterService) + props["IsTrial"] = strconv.FormatBool(l.IsTrial) } return props } + +func GetSanitizedClientLicense(l map[string]string) map[string]string { + sanitizedLicense := make(map[string]string) + + for k, v := range l { + sanitizedLicense[k] = v + } + + delete(sanitizedLicense, "Id") + delete(sanitizedLicense, "Name") + delete(sanitizedLicense, "Email") + delete(sanitizedLicense, "IssuedAt") + delete(sanitizedLicense, "StartsAt") + delete(sanitizedLicense, "ExpiresAt") + delete(sanitizedLicense, "SkuName") + delete(sanitizedLicense, "SkuShortName") + + return sanitizedLicense +} diff --git a/utils/license_test.go b/utils/license_test.go index f540e81ec9..5fdb614988 100644 --- a/utils/license_test.go +++ b/utils/license_test.go @@ -14,11 +14,11 @@ import ( func TestValidateLicense(t *testing.T) { b1 := []byte("junk") - ok, _ := ValidateLicense(b1) + ok, _ := LicenseValidator.ValidateLicense(b1) require.False(t, ok, "should have failed - bad license") b2 := []byte("junkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunkjunk") - ok, _ = ValidateLicense(b2) + ok, _ = LicenseValidator.ValidateLicense(b2) require.False(t, ok, "should have failed - bad license") } @@ -45,7 +45,7 @@ func TestGetLicenseFileFromDisk(t *testing.T) { fileBytes := GetLicenseFileFromDisk(f.Name()) require.NotEmpty(t, fileBytes, "should have read the file") - success, _ := ValidateLicense(fileBytes) + success, _ := LicenseValidator.ValidateLicense(fileBytes) assert.False(t, success, "should have been an invalid file") }) } diff --git a/utils/mocks/LicenseValidatorIface.go b/utils/mocks/LicenseValidatorIface.go new file mode 100644 index 0000000000..880d360a89 --- /dev/null +++ b/utils/mocks/LicenseValidatorIface.go @@ -0,0 +1,61 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +// Regenerate this file using `make misc-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v5/model" + mock "github.com/stretchr/testify/mock" +) + +// LicenseValidatorIface is an autogenerated mock type for the LicenseValidatorIface type +type LicenseValidatorIface struct { + mock.Mock +} + +// LicenseFromBytes provides a mock function with given fields: licenseBytes +func (_m *LicenseValidatorIface) LicenseFromBytes(licenseBytes []byte) (*model.License, *model.AppError) { + ret := _m.Called(licenseBytes) + + var r0 *model.License + if rf, ok := ret.Get(0).(func([]byte) *model.License); ok { + r0 = rf(licenseBytes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.License) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func([]byte) *model.AppError); ok { + r1 = rf(licenseBytes) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// ValidateLicense provides a mock function with given fields: signed +func (_m *LicenseValidatorIface) ValidateLicense(signed []byte) (bool, string) { + ret := _m.Called(signed) + + var r0 bool + if rf, ok := ret.Get(0).(func([]byte) bool); ok { + r0 = rf(signed) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 string + if rf, ok := ret.Get(1).(func([]byte) string); ok { + r1 = rf(signed) + } else { + r1 = ret.Get(1).(string) + } + + return r0, r1 +} diff --git a/utils/testutils/testutils.go b/utils/testutils/testutils.go index e2568e0e3d..02914fbf7f 100644 --- a/utils/testutils/testutils.go +++ b/utils/testutils/testutils.go @@ -14,6 +14,8 @@ import ( "strconv" "time" + "github.com/mattermost/mattermost-server/v5/utils" + "github.com/mattermost/mattermost-server/v5/utils/fileutils" ) @@ -72,3 +74,7 @@ func GetInterface(port int) string { } return string(out) } + +func ResetLicenseValidator() { + utils.LicenseValidator = &utils.LicenseValidatorImpl{} +}