* feat: Replace 5% grace period with configurable ExtraUsers field - Rename ExtraSeats to ExtraUsers in license Features struct - Remove fixed 5% grace period and minimum 1 extra user logic - Add configurable ExtraUsers field that allows exact control over additional seats - Update calculateGraceLimit() to use extraUsers parameter directly - When ExtraUsers is nil, defaults to 0 (hard cap with no overage) - Special case maintained: zero user licenses always return 0 grace limit - Update all tests to use new ExtraUsers functionality Closes #31628 Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * feat: eliminate calculateGraceLimit function, use inline baseLimit + extraUsers - Remove calculateGraceLimit function and replace with inline calculation - Allow extraUsers even when baseLimit is 0 (behavioral change) - Update tests to reflect new behavior - Remove TestCalculateGraceLimit since function no longer exists Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * feat: move ExtraUsers field to top level License struct Move ExtraUsers field from Features struct to the top level License struct for better organization and direct access. Update all references in limits.go and limits_test.go to use the new field location. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * feat: use model.NewPointer for creating integer pointers in tests Replace inline function declarations with model.NewPointer calls for cleaner code. Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * feat: reorder ExtraUsers field to be after IsSeatCountEnforced Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * fix: format Go files with gofmt - Remove extra blank line in limits.go - Align struct fields in limits_test.go table test Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> * Fix user limits tests and document ExtraUsers field - Fix TestCreateUserOrGuestSeatCountEnforcement to use ExtraUsers instead of old grace period - Add documentation to ExtraUsers field explaining it as a grace mechanism - Update test comments to reflect hard limit terminology 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Jesse Hallam <lieut-data@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
492 строки
15 KiB
Go
492 строки
15 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DayInSeconds = 24 * 60 * 60
|
|
DayInMilliseconds = DayInSeconds * 1000
|
|
|
|
ExpiredLicenseError = "api.license.add_license.expired.app_error"
|
|
InvalidLicenseError = "api.license.add_license.invalid.app_error"
|
|
LicenseGracePeriod = DayInMilliseconds * 10 //10 days
|
|
LicenseRenewalLink = "https://mattermost.com/renew/"
|
|
|
|
LicenseShortSkuE10 = "E10"
|
|
LicenseShortSkuE20 = "E20"
|
|
LicenseShortSkuProfessional = "professional"
|
|
LicenseShortSkuEnterprise = "enterprise"
|
|
LicenseShortSkuEnterpriseAdvanced = "advanced"
|
|
|
|
ProfessionalTier = 10
|
|
EnterpriseTier = 20
|
|
EnterpriseAdvancedTier = 30
|
|
)
|
|
|
|
var LicenseToLicenseTier = map[string]int{
|
|
LicenseShortSkuProfessional: ProfessionalTier,
|
|
LicenseShortSkuEnterprise: EnterpriseTier,
|
|
LicenseShortSkuEnterpriseAdvanced: EnterpriseAdvancedTier,
|
|
}
|
|
|
|
const (
|
|
LicenseUpForRenewalEmailSent = "LicenseUpForRenewalEmailSent"
|
|
)
|
|
|
|
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"`
|
|
Bytes string `json:"-"`
|
|
}
|
|
|
|
type License struct {
|
|
Id string `json:"id"`
|
|
IssuedAt int64 `json:"issued_at"`
|
|
StartsAt int64 `json:"starts_at"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
Customer *Customer `json:"customer"`
|
|
Features *Features `json:"features"`
|
|
SkuName string `json:"sku_name"`
|
|
SkuShortName string `json:"sku_short_name"`
|
|
IsTrial bool `json:"is_trial"`
|
|
IsGovSku bool `json:"is_gov_sku"`
|
|
IsSeatCountEnforced bool `json:"is_seat_count_enforced"`
|
|
// ExtraUsers provides a grace mechanism that allows a configurable number of users
|
|
// beyond the base license limit before restricting user creation. When nil, defaults to 0.
|
|
// For example: 100 licensed users + 5 ExtraUsers = 105 total allowed users.
|
|
ExtraUsers *int `json:"extra_users"`
|
|
SignupJWT *string `json:"signup_jwt"`
|
|
}
|
|
|
|
type Customer struct {
|
|
Id string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Company string `json:"company"`
|
|
}
|
|
|
|
type TrialLicenseRequest struct {
|
|
ServerID string `json:"server_id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
SiteURL string `json:"site_url"`
|
|
SiteName string `json:"site_name"`
|
|
Users int `json:"users"`
|
|
TermsAccepted bool `json:"terms_accepted"`
|
|
ReceiveEmailsAccepted bool `json:"receive_emails_accepted"`
|
|
ContactName string `json:"contact_name"`
|
|
ContactEmail string `json:"contact_email"`
|
|
CompanyName string `json:"company_name"`
|
|
CompanyCountry string `json:"company_country"`
|
|
CompanySize string `json:"company_size"`
|
|
ServerVersion string `json:"server_version"`
|
|
}
|
|
|
|
// If any of the below fields are set, this is not a legacy request, and all fields should be validated
|
|
func (tlr *TrialLicenseRequest) IsLegacy() bool {
|
|
return tlr.CompanyCountry == "" && tlr.CompanyName == "" && tlr.CompanySize == "" && tlr.ContactName == ""
|
|
}
|
|
|
|
func (tlr *TrialLicenseRequest) IsValid() bool {
|
|
if !tlr.TermsAccepted {
|
|
return false
|
|
}
|
|
|
|
if tlr.Email == "" {
|
|
return false
|
|
}
|
|
|
|
if tlr.Users <= 0 {
|
|
return false
|
|
}
|
|
|
|
if tlr.CompanyCountry == "" {
|
|
return false
|
|
}
|
|
|
|
if tlr.CompanyName == "" {
|
|
return false
|
|
}
|
|
|
|
if tlr.CompanySize == "" {
|
|
return false
|
|
}
|
|
|
|
if tlr.ContactName == "" {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
type Features struct {
|
|
Users *int `json:"users"`
|
|
LDAP *bool `json:"ldap"`
|
|
LDAPGroups *bool `json:"ldap_groups"`
|
|
MFA *bool `json:"mfa"`
|
|
GoogleOAuth *bool `json:"google_oauth"`
|
|
Office365OAuth *bool `json:"office365_oauth"`
|
|
OpenId *bool `json:"openid"`
|
|
Compliance *bool `json:"compliance"`
|
|
Cluster *bool `json:"cluster"`
|
|
Metrics *bool `json:"metrics"`
|
|
MHPNS *bool `json:"mhpns"`
|
|
SAML *bool `json:"saml"`
|
|
Elasticsearch *bool `json:"elastic_search"`
|
|
Announcement *bool `json:"announcement"`
|
|
ThemeManagement *bool `json:"theme_management"`
|
|
EmailNotificationContents *bool `json:"email_notification_contents"`
|
|
DataRetention *bool `json:"data_retention"`
|
|
MessageExport *bool `json:"message_export"`
|
|
CustomPermissionsSchemes *bool `json:"custom_permissions_schemes"`
|
|
CustomTermsOfService *bool `json:"custom_terms_of_service"`
|
|
GuestAccounts *bool `json:"guest_accounts"`
|
|
GuestAccountsPermissions *bool `json:"guest_accounts_permissions"`
|
|
IDLoadedPushNotifications *bool `json:"id_loaded"`
|
|
LockTeammateNameDisplay *bool `json:"lock_teammate_name_display"`
|
|
EnterprisePlugins *bool `json:"enterprise_plugins"`
|
|
AdvancedLogging *bool `json:"advanced_logging"`
|
|
Cloud *bool `json:"cloud"`
|
|
SharedChannels *bool `json:"shared_channels"`
|
|
RemoteClusterService *bool `json:"remote_cluster_service"`
|
|
OutgoingOAuthConnections *bool `json:"outgoing_oauth_connections"`
|
|
|
|
// after we enabled more features we'll need to control them with this
|
|
FutureFeatures *bool `json:"future_features"`
|
|
}
|
|
|
|
func (f *Features) ToMap() map[string]any {
|
|
return map[string]any{
|
|
"ldap": *f.LDAP,
|
|
"ldap_groups": *f.LDAPGroups,
|
|
"mfa": *f.MFA,
|
|
"google": *f.GoogleOAuth,
|
|
"office365": *f.Office365OAuth,
|
|
"openid": *f.OpenId,
|
|
"compliance": *f.Compliance,
|
|
"cluster": *f.Cluster,
|
|
"metrics": *f.Metrics,
|
|
"mhpns": *f.MHPNS,
|
|
"saml": *f.SAML,
|
|
"elastic_search": *f.Elasticsearch,
|
|
"email_notification_contents": *f.EmailNotificationContents,
|
|
"data_retention": *f.DataRetention,
|
|
"message_export": *f.MessageExport,
|
|
"custom_permissions_schemes": *f.CustomPermissionsSchemes,
|
|
"guest_accounts": *f.GuestAccounts,
|
|
"guest_accounts_permissions": *f.GuestAccountsPermissions,
|
|
"id_loaded": *f.IDLoadedPushNotifications,
|
|
"lock_teammate_name_display": *f.LockTeammateNameDisplay,
|
|
"enterprise_plugins": *f.EnterprisePlugins,
|
|
"advanced_logging": *f.AdvancedLogging,
|
|
"cloud": *f.Cloud,
|
|
"shared_channels": *f.SharedChannels,
|
|
"remote_cluster_service": *f.RemoteClusterService,
|
|
"future": *f.FutureFeatures,
|
|
"outgoing_oauth_connections": *f.OutgoingOAuthConnections,
|
|
}
|
|
}
|
|
|
|
func (f *Features) SetDefaults() {
|
|
if f.FutureFeatures == nil {
|
|
f.FutureFeatures = NewPointer(true)
|
|
}
|
|
|
|
if f.Users == nil {
|
|
f.Users = NewPointer(0)
|
|
}
|
|
|
|
if f.LDAP == nil {
|
|
f.LDAP = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.LDAPGroups == nil {
|
|
f.LDAPGroups = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.MFA == nil {
|
|
f.MFA = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.GoogleOAuth == nil {
|
|
f.GoogleOAuth = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Office365OAuth == nil {
|
|
f.Office365OAuth = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.OpenId == nil {
|
|
f.OpenId = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Compliance == nil {
|
|
f.Compliance = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Cluster == nil {
|
|
f.Cluster = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Metrics == nil {
|
|
f.Metrics = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.MHPNS == nil {
|
|
f.MHPNS = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.SAML == nil {
|
|
f.SAML = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Elasticsearch == nil {
|
|
f.Elasticsearch = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Announcement == nil {
|
|
f.Announcement = NewPointer(true)
|
|
}
|
|
|
|
if f.ThemeManagement == nil {
|
|
f.ThemeManagement = NewPointer(true)
|
|
}
|
|
|
|
if f.EmailNotificationContents == nil {
|
|
f.EmailNotificationContents = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.DataRetention == nil {
|
|
f.DataRetention = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.MessageExport == nil {
|
|
f.MessageExport = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.CustomPermissionsSchemes == nil {
|
|
f.CustomPermissionsSchemes = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.GuestAccounts == nil {
|
|
f.GuestAccounts = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.GuestAccountsPermissions == nil {
|
|
f.GuestAccountsPermissions = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.CustomTermsOfService == nil {
|
|
f.CustomTermsOfService = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.IDLoadedPushNotifications == nil {
|
|
f.IDLoadedPushNotifications = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.LockTeammateNameDisplay == nil {
|
|
f.LockTeammateNameDisplay = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.EnterprisePlugins == nil {
|
|
f.EnterprisePlugins = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.AdvancedLogging == nil {
|
|
f.AdvancedLogging = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.Cloud == nil {
|
|
f.Cloud = NewPointer(false)
|
|
}
|
|
|
|
if f.SharedChannels == nil {
|
|
f.SharedChannels = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.RemoteClusterService == nil {
|
|
f.RemoteClusterService = NewPointer(*f.FutureFeatures)
|
|
}
|
|
|
|
if f.OutgoingOAuthConnections == nil {
|
|
f.OutgoingOAuthConnections = NewPointer(*f.FutureFeatures)
|
|
}
|
|
}
|
|
|
|
func (l *License) IsExpired() bool {
|
|
return l.ExpiresAt < GetMillis()
|
|
}
|
|
|
|
func (l *License) IsPastGracePeriod() bool {
|
|
timeDiff := GetMillis() - l.ExpiresAt
|
|
return timeDiff > LicenseGracePeriod
|
|
}
|
|
|
|
func (l *License) IsWithinExpirationPeriod() bool {
|
|
days := l.DaysToExpiration()
|
|
return days <= 60 && days >= 58
|
|
}
|
|
|
|
func (l *License) DaysToExpiration() int {
|
|
dif := l.ExpiresAt - GetMillis()
|
|
d, _ := time.ParseDuration(fmt.Sprint(dif) + "ms")
|
|
days := d.Hours() / 24
|
|
return int(days)
|
|
}
|
|
|
|
func (l *License) IsStarted() bool {
|
|
return l.StartsAt < GetMillis()
|
|
}
|
|
|
|
// Cloud preview is a cloud license, that is also a trial, and the difference between the start and end date is exactly 1 hour.
|
|
func (l *License) IsCloudPreview() bool {
|
|
return l.IsCloud() && l.IsTrialLicense() && l.ExpiresAt-l.StartsAt == 1*time.Hour.Milliseconds()
|
|
}
|
|
|
|
func (l *License) IsCloud() bool {
|
|
return l != nil && l.Features != nil && l.Features.Cloud != nil && *l.Features.Cloud
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
func (l *License) HasEnterpriseMarketplacePlugins() bool {
|
|
return *l.Features.EnterprisePlugins ||
|
|
l.SkuShortName == LicenseShortSkuE20 ||
|
|
MinimumProfessionalLicense(l)
|
|
}
|
|
|
|
func (l *License) HasRemoteClusterService() bool {
|
|
if l == nil {
|
|
return false
|
|
}
|
|
|
|
// If SharedChannels is enabled then RemoteClusterService must be enabled.
|
|
if l.HasSharedChannels() {
|
|
return true
|
|
}
|
|
|
|
return (l.Features != nil && l.Features.RemoteClusterService != nil && *l.Features.RemoteClusterService) ||
|
|
MinimumProfessionalLicense(l)
|
|
}
|
|
|
|
func (l *License) HasSharedChannels() bool {
|
|
if l == nil {
|
|
return false
|
|
}
|
|
|
|
return (l.Features != nil && l.Features.SharedChannels != nil && *l.Features.SharedChannels) ||
|
|
MinimumProfessionalLicense(l)
|
|
}
|
|
|
|
// NewTestLicense returns a license that expires in the future and has the given features.
|
|
func NewTestLicense(features ...string) *License {
|
|
ret := &License{
|
|
ExpiresAt: GetMillis() + 90*DayInMilliseconds,
|
|
Customer: &Customer{
|
|
Id: "some ID",
|
|
Email: "admin@example.com",
|
|
Name: "Main Contact Person",
|
|
Company: "My awesome Company",
|
|
},
|
|
Features: &Features{},
|
|
}
|
|
ret.Features.SetDefaults()
|
|
|
|
featureMap := map[string]bool{}
|
|
for _, feature := range features {
|
|
featureMap[feature] = true
|
|
}
|
|
featureJson, _ := json.Marshal(featureMap)
|
|
json.Unmarshal(featureJson, &ret.Features)
|
|
|
|
return ret
|
|
}
|
|
|
|
// NewTestLicense returns a license that expires in the future and set as false the given features.
|
|
func NewTestLicenseWithFalseDefaults(features ...string) *License {
|
|
ret := &License{
|
|
ExpiresAt: GetMillis() + 90*DayInMilliseconds,
|
|
Customer: &Customer{},
|
|
Features: &Features{},
|
|
}
|
|
ret.Features.SetDefaults()
|
|
|
|
featureMap := map[string]bool{}
|
|
for _, feature := range features {
|
|
featureMap[feature] = false
|
|
}
|
|
featureJson, _ := json.Marshal(featureMap)
|
|
json.Unmarshal(featureJson, &ret.Features)
|
|
|
|
return ret
|
|
}
|
|
|
|
func NewTestLicenseSKU(skuShortName string, features ...string) *License {
|
|
lic := NewTestLicense(features...)
|
|
lic.SkuShortName = skuShortName
|
|
return lic
|
|
}
|
|
|
|
func (lr *LicenseRecord) IsValid() *AppError {
|
|
if !IsValidId(lr.Id) {
|
|
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if lr.CreateAt == 0 {
|
|
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.create_at.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if lr.Bytes == "" || len(lr.Bytes) > 10000 {
|
|
return NewAppError("LicenseRecord.IsValid", "model.license_record.is_valid.bytes.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (lr *LicenseRecord) PreSave() {
|
|
lr.CreateAt = GetMillis()
|
|
}
|
|
|
|
// MinimumProfessionalLicense returns true if the provided license is at least a professional license.
|
|
// Higher tier licenses also satisfy the condition.
|
|
func MinimumProfessionalLicense(license *License) bool {
|
|
return license != nil && LicenseToLicenseTier[license.SkuShortName] >= ProfessionalTier
|
|
}
|
|
|
|
// MinimumEnterpriseLicense returns true if the provided license is at least a enterprise license.
|
|
// Higher tier licenses also satisfy the condition.
|
|
func MinimumEnterpriseLicense(license *License) bool {
|
|
return license != nil && LicenseToLicenseTier[license.SkuShortName] >= EnterpriseTier
|
|
}
|
|
|
|
// MinimumEnterpriseAdvancedLicense returns true if the provided license is at least an Enterprise Advanced license.
|
|
func MinimumEnterpriseAdvancedLicense(license *License) bool {
|
|
return license != nil && LicenseToLicenseTier[license.SkuShortName] >= EnterpriseAdvancedTier
|
|
}
|