feat: Replace 5% grace period with configurable ExtraUsers field (#31629)

* 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 &lt;lieut-data@users.noreply.github.com&gt;

* 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>
This commit is contained in:
Jesse Hallam 2025-06-17 16:56:52 -03:00 committed by GitHub
parent 744d284069
commit e367872c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 127 additions and 114 deletions

View file

@ -14,21 +14,6 @@ const (
maxUsersHardLimit = 5_000
)
// calculateGraceLimit calculates a grace limit that is 5% above the base limit
// or at least 1 user above the base limit, whichever is higher.
// Special case: if baseLimit is 0, returns 0.
func calculateGraceLimit(baseLimit int64) int64 {
if baseLimit == 0 {
return 0
}
graceFromPercentage := int64(float64(baseLimit) * 1.05)
graceFromFloor := baseLimit + 1
if graceFromPercentage > graceFromFloor {
return graceFromPercentage
}
return graceFromFloor
}
func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
limits := &model.ServerLimits{}
license := a.License()
@ -38,10 +23,17 @@ func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
limits.MaxUsersLimit = maxUsersLimit
limits.MaxUsersHardLimit = maxUsersHardLimit
} else if license != nil && license.IsSeatCountEnforced && license.Features != nil && license.Features.Users != nil {
// Enforce license limits as required by the license with grace period.
// Enforce license limits as required by the license with configurable extra users.
licenseUserLimit := int64(*license.Features.Users)
limits.MaxUsersLimit = licenseUserLimit
limits.MaxUsersHardLimit = calculateGraceLimit(licenseUserLimit)
// Use ExtraUsers if configured, otherwise default to 0 (no extra users)
extraUsers := 0
if license.ExtraUsers != nil {
extraUsers = *license.ExtraUsers
}
limits.MaxUsersHardLimit = licenseUserLimit + int64(extraUsers)
}
activeUserCount, appErr := a.Srv().Store().User().Count(model.UserCountOptions{})

View file

@ -168,14 +168,16 @@ func TestGetServerLimits(t *testing.T) {
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
})
t.Run("licensed server with seat count enforcement shows license limits with grace period", func(t *testing.T) {
t.Run("licensed server with seat count enforcement shows license limits with configurable extra users", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userLimit := 100
extraUsers := 10
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
@ -184,7 +186,48 @@ func TestGetServerLimits(t *testing.T) {
// InitBasic creates 3 users by default
require.Equal(t, int64(3), serverLimits.ActiveUserCount)
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
require.Equal(t, int64(105), serverLimits.MaxUsersHardLimit) // 100 + 5% = 105
require.Equal(t, int64(110), serverLimits.MaxUsersHardLimit) // 100 + 10 extra users = 110
})
t.Run("licensed server with seat count enforcement and no ExtraUsers configured defaults to zero", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userLimit := 100
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = nil // Not configured
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// InitBasic creates 3 users by default
require.Equal(t, int64(3), serverLimits.ActiveUserCount)
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
require.Equal(t, int64(100), serverLimits.MaxUsersHardLimit) // 100 + 0 extra users = 100 (hard cap)
})
t.Run("licensed server with seat count enforcement and zero ExtraUsers creates hard cap", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userLimit := 100
extraUsers := 0
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// InitBasic creates 3 users by default
require.Equal(t, int64(3), serverLimits.ActiveUserCount)
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
require.Equal(t, int64(100), serverLimits.MaxUsersHardLimit) // 100 + 0 extra users = 100 (hard cap)
})
t.Run("licensed server with seat count enforcement but no Users feature shows no limits", func(t *testing.T) {
@ -219,7 +262,7 @@ func TestGetServerLimits(t *testing.T) {
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit) // No grace for 0 users
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit) // 0 + 0 (default) extra users = 0
})
}
@ -293,37 +336,41 @@ func TestIsAtUserLimit(t *testing.T) {
require.False(t, atLimit)
})
t.Run("at base limit but below grace limit", func(t *testing.T) {
t.Run("at base limit but below hard limit with extra users", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userLimit := 5
extraUsers := 2
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
// Create 2 additional users to have 5 total (at base limit of 5, but below grace limit of 6)
// Create 2 additional users to have 5 total (at base limit of 5, but below hard limit of 7)
th.CreateUser()
th.CreateUser()
atLimit, appErr := th.App.isAtUserLimit()
require.Nil(t, appErr)
require.False(t, atLimit) // Should be false due to grace period
require.False(t, atLimit) // Should be false due to extra users
})
t.Run("at grace limit", func(t *testing.T) {
t.Run("at hard limit with extra users", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
userLimit := 5
extraUsers := 1
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(6), nil) // At grace limit of 6 (5 + 1)
mockUserStore.On("Count", mock.Anything).Return(int64(6), nil) // At hard limit of 6 (5 + 1)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockStore.On("User").Return(&mockUserStore)
@ -332,18 +379,20 @@ func TestIsAtUserLimit(t *testing.T) {
require.True(t, atLimit)
})
t.Run("above grace limit", func(t *testing.T) {
t.Run("above hard limit with extra users", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
userLimit := 5
extraUsers := 1
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(7), nil) // Above grace limit of 6
mockUserStore.On("Count", mock.Anything).Return(int64(7), nil) // Above hard limit of 6
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockStore.On("User").Return(&mockUserStore)
@ -418,39 +467,51 @@ func TestIsAtUserLimit(t *testing.T) {
})
}
func TestGracePeriodBehavior(t *testing.T) {
func TestExtraUsersBehavior(t *testing.T) {
mainHelper.Parallel(t)
t.Run("grace period examples", func(t *testing.T) {
t.Run("extra users examples", func(t *testing.T) {
tests := []struct {
name string
licenseUserLimit int
expectedBaseLimit int64
expectedGraceLimit int64
name string
licenseUserLimit int
extraUsers *int
expectedBaseLimit int64
expectedHardLimit int64
}{
{
name: "zero license users gets zero grace",
licenseUserLimit: 0,
expectedBaseLimit: 0,
expectedGraceLimit: 0, // Special case: 0 users = 0 grace limit
name: "zero license users with extra users",
licenseUserLimit: 0,
extraUsers: model.NewPointer(5),
expectedBaseLimit: 0,
expectedHardLimit: 5, // 0 + 5 extra users = 5
},
{
name: "small license uses floor (10 users)",
licenseUserLimit: 10,
expectedBaseLimit: 10,
expectedGraceLimit: 11, // 10 + max(5%, 1) = 10 + 1
name: "license with configured extra users",
licenseUserLimit: 10,
extraUsers: model.NewPointer(2),
expectedBaseLimit: 10,
expectedHardLimit: 12, // 10 + 2 extra users = 12
},
{
name: "medium license uses percentage (100 users)",
licenseUserLimit: 100,
expectedBaseLimit: 100,
expectedGraceLimit: 105, // 100 + max(5%, 1) = 100 + 5
name: "license with zero extra users (hard cap)",
licenseUserLimit: 100,
extraUsers: model.NewPointer(0),
expectedBaseLimit: 100,
expectedHardLimit: 100, // 100 + 0 extra users = 100 (hard cap)
},
{
name: "large license uses percentage (1000 users)",
licenseUserLimit: 1000,
expectedBaseLimit: 1000,
expectedGraceLimit: 1050, // 1000 + max(5%, 1) = 1000 + 50
name: "license with no extra users configured defaults to zero",
licenseUserLimit: 100,
extraUsers: nil,
expectedBaseLimit: 100,
expectedHardLimit: 100, // 100 + 0 (default) extra users = 100 (hard cap)
},
{
name: "license with large number of extra users",
licenseUserLimit: 1000,
extraUsers: model.NewPointer(200),
expectedBaseLimit: 1000,
expectedHardLimit: 1200, // 1000 + 200 extra users = 1200
},
}
@ -462,18 +523,19 @@ func TestGracePeriodBehavior(t *testing.T) {
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &tt.licenseUserLimit
license.ExtraUsers = tt.extraUsers
th.App.Srv().SetLicense(license)
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
require.Equal(t, tt.expectedBaseLimit, serverLimits.MaxUsersLimit)
require.Equal(t, tt.expectedGraceLimit, serverLimits.MaxUsersHardLimit)
require.Equal(t, tt.expectedHardLimit, serverLimits.MaxUsersHardLimit)
})
}
})
t.Run("unlicensed server has no grace period", func(t *testing.T) {
t.Run("unlicensed server has no extra users", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
@ -482,61 +544,8 @@ func TestGracePeriodBehavior(t *testing.T) {
serverLimits, appErr := th.App.GetServerLimits()
require.Nil(t, appErr)
// Unlicensed servers should not get grace period
// Unlicensed servers use hard-coded limits without extra users
require.Equal(t, int64(2500), serverLimits.MaxUsersLimit)
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit) // No grace, stays at 5000
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit)
})
}
func TestCalculateGraceLimit(t *testing.T) {
mainHelper.Parallel(t)
tests := []struct {
name string
baseLimit int64
expected int64
}{
{
name: "zero base limit",
baseLimit: 0,
expected: 0, // Special case: 0 users = 0 grace limit
},
{
name: "one user base limit",
baseLimit: 1,
expected: 2, // max(1 * 1.05, 1 + 1) = max(1.05 -> 1, 2) = 2
},
{
name: "small base limit where floor applies",
baseLimit: 10,
expected: 11, // max(10 * 1.05, 10 + 1) = max(10.5 -> 10, 11) = 11
},
{
name: "small base limit where percentage applies",
baseLimit: 20,
expected: 21, // max(20 * 1.05, 20 + 1) = max(21, 21) = 21
},
{
name: "medium base limit where percentage applies",
baseLimit: 100,
expected: 105, // max(100 * 1.05, 100 + 1) = max(105, 101) = 105
},
{
name: "large base limit where percentage applies",
baseLimit: 1000,
expected: 1050, // max(1000 * 1.05, 1000 + 1) = max(1050, 1001) = 1050
},
{
name: "very large base limit",
baseLimit: 5000,
expected: 5250, // max(5000 * 1.05, 5000 + 1) = max(5250, 5001) = 5250
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculateGraceLimit(tt.baseLimit)
require.Equal(t, tt.expected, result, "calculateGraceLimit(%d) = %d, expected %d", tt.baseLimit, result, tt.expected)
})
}
}

View file

@ -306,18 +306,20 @@ func TestCreateUserOrGuestSeatCountEnforcement(t *testing.T) {
defer th.TearDown()
userLimit := 5
extraUsers := 1
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
// Create 3 additional users to reach the grace limit of 6 (3 from InitBasic + 3)
// Grace limit for 5 users is 6 (5% grace period)
// Create 3 additional users to reach the hard limit of 6 (3 from InitBasic + 3)
// Hard limit = 5 base users + 1 extra user = 6 total
th.CreateUser()
th.CreateUser()
th.CreateUser()
// Now at grace limit - attempting to create another user should fail
// Now at hard limit - attempting to create another user should fail
user := &model.User{
Email: "TestSeatCount@example.com",
Username: "seat_test_user",
@ -337,7 +339,8 @@ func TestCreateUserOrGuestSeatCountEnforcement(t *testing.T) {
defer th.TearDown()
userLimit := 5
currentUserCount := int64(6) // Over limit
extraUsers := 0
currentUserCount := int64(6) // Over limit (limit=5, hard limit=5+0=5, current=6)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(currentUserCount, nil)
@ -353,6 +356,7 @@ func TestCreateUserOrGuestSeatCountEnforcement(t *testing.T) {
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
user := &model.User{
@ -445,18 +449,20 @@ func TestCreateUserOrGuestSeatCountEnforcement(t *testing.T) {
defer th.TearDown()
userLimit := 5
extraUsers := 1
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
// Create 3 additional users to reach the grace limit of 6 (3 from InitBasic + 3)
// Grace limit for 5 users is 6 (5% grace period)
// Create 3 additional users to reach the hard limit of 6 (3 from InitBasic + 3)
// Hard limit = 5 base users + 1 extra user = 6 total
th.CreateUser()
th.CreateUser()
th.CreateUser()
// Now at grace limit - attempting to create a guest should fail
// Now at hard limit - attempting to create a guest should fail
user := &model.User{
Email: "TestSeatCountGuest@example.com",
Username: "seat_test_guest",
@ -475,9 +481,11 @@ func TestCreateUserOrGuestSeatCountEnforcement(t *testing.T) {
defer th.TearDown()
userLimit := 5
extraUsers := 0
license := model.NewTestLicense("")
license.IsSeatCountEnforced = true
license.Features.Users = &userLimit
license.ExtraUsers = &extraUsers
th.App.Srv().SetLicense(license)
// InitBasic creates 3 users, so we're under the limit of 5

View file

@ -68,7 +68,11 @@ type License struct {
IsTrial bool `json:"is_trial"`
IsGovSku bool `json:"is_gov_sku"`
IsSeatCountEnforced bool `json:"is_seat_count_enforced"`
SignupJWT *string `json:"signup_jwt"`
// 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 {