Skip to content

Commit 6f238ac

Browse files
author
Simon Emms
committed
[licensor]: introduce concept of a fallback license with limited features
The Enabled function now has knowledge of the number of seats in use. If this is still within range, the features are checked against the loaded license. If not, they will be checked against the fallback license. The fallback is optional, based upon the license type - Gitpod licenses always disable fallback. Replicated licenses disable fallback if it's a paid license. This is so paying customers aren't inconvenienced by losing features - instead, they will be unable to add additional users, as is the current behaviour.
1 parent 435d47d commit 6f238ac

File tree

8 files changed

+158
-60
lines changed

8 files changed

+158
-60
lines changed

components/licensor/ee/pkg/licensor/gitpod.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) {
6161
}
6262

6363
return &Evaluator{
64-
lic: lic.LicensePayload,
64+
lic: lic.LicensePayload,
65+
allowFallback: false, // Gitpod licenses cannot fallback - assume these are always paid-for
6566
}
6667
}

components/licensor/ee/pkg/licensor/licensor.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,18 @@ func (lvl LicenseLevel) allowance() allowance {
117117
return a
118118
}
119119

120+
// Fallback license is used when the instance exceeds the number of licenses - it allows limited access
121+
var fallbackLicense = LicensePayload{
122+
ID: "fallback-license",
123+
Level: LevelTeam,
124+
Seats: 0,
125+
// Domain, ValidUntil are free for all
126+
}
127+
128+
// Default license is used when no valid license is given - it allows full access up to 10 users
120129
var defaultLicense = LicensePayload{
121130
ID: "default-license",
122-
Level: LevelTeam,
131+
Level: LevelEnterprise,
123132
Seats: 10,
124133
// Domain, ValidUntil are free for all
125134
}
@@ -144,8 +153,9 @@ func matchesDomain(pattern, domain string) bool {
144153

145154
// Evaluator determines what a license allows for
146155
type Evaluator struct {
147-
invalid string
148-
lic LicensePayload
156+
invalid string
157+
allowFallback bool // Paid licenses cannot fallback and prevent additional signups
158+
lic LicensePayload
149159
}
150160

151161
// Validate returns false if the license isn't valid and a message explaining why that is.
@@ -158,24 +168,45 @@ func (e *Evaluator) Validate() (msg string, valid bool) {
158168
}
159169

160170
// Enabled determines if a feature is enabled by the license
161-
func (e *Evaluator) Enabled(feature Feature) bool {
171+
func (e *Evaluator) Enabled(feature Feature, seats int) bool {
162172
if e.invalid != "" {
163173
return false
164174
}
165175

166-
_, ok := e.lic.Level.allowance().Features[feature]
176+
var ok bool
177+
if e.hasEnoughSeats(seats) {
178+
// License has enough seats available - evaluate this license
179+
_, ok = e.lic.Level.allowance().Features[feature]
180+
} else if e.allowFallback {
181+
// License has run out of seats - use the fallback license
182+
_, ok = fallbackLicense.Level.allowance().Features[feature]
183+
}
184+
167185
return ok
168186
}
169187

170-
// HasEnoughSeats returns true if the license supports at least the give amount of seats
171-
func (e *Evaluator) HasEnoughSeats(seats int) bool {
188+
// hasEnoughSeats returns true if the license supports at least the give amount of seats
189+
func (e *Evaluator) hasEnoughSeats(seats int) bool {
172190
if e.invalid != "" {
173191
return false
174192
}
175193

176194
return e.lic.Seats == 0 || seats <= e.lic.Seats
177195
}
178196

197+
// HasEnoughSeats is the public method to hasEnoughSeats. Will use fallback license if allowable
198+
func (e *Evaluator) HasEnoughSeats(seats int) bool {
199+
if e.invalid != "" {
200+
return false
201+
}
202+
203+
if !e.allowFallback {
204+
return e.hasEnoughSeats(seats)
205+
}
206+
// There is always more space if can use a fallback license
207+
return true
208+
}
209+
179210
// Inspect returns the license information this evaluator holds.
180211
// This function is intended for transparency/debugging purposes only and must
181212
// never be used to determine feature eligibility under a license. All code making

components/licensor/ee/pkg/licensor/licensor_test.go

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ const (
2323
)
2424

2525
type licenseTest struct {
26-
Name string
27-
License *LicensePayload
28-
Validate func(t *testing.T, eval *Evaluator)
29-
Type LicenseType
30-
NeverExpires bool
26+
Name string
27+
License *LicensePayload
28+
Validate func(t *testing.T, eval *Evaluator)
29+
Type LicenseType
30+
NeverExpires bool
31+
ReplicatedLicenseType *ReplicatedLicenseType
3132
}
3233

3334
// roundTripFunc .
@@ -76,6 +77,12 @@ func (test *licenseTest) Run(t *testing.T) {
7677
}
7778

7879
payload, err := json.Marshal(replicatedLicensePayload{
80+
LicenseType: func() ReplicatedLicenseType {
81+
if test.ReplicatedLicenseType == nil {
82+
return ReplicatedLicenseTypePaid
83+
}
84+
return *test.ReplicatedLicenseType
85+
}(),
7986
ExpirationTime: func() *time.Time {
8087
if test.License != nil {
8188
return &test.License.ValidUntil
@@ -96,15 +103,6 @@ func (test *licenseTest) Run(t *testing.T) {
96103
return domain
97104
}(),
98105
},
99-
{
100-
Field: "levelId",
101-
Value: func() LicenseLevel {
102-
if test.License != nil {
103-
return test.License.Level
104-
}
105-
return LevelTeam
106-
}(),
107-
},
108106
{
109107
Field: "seats",
110108
Value: func() int {
@@ -196,9 +194,9 @@ func TestSeats(t *testing.T) {
196194
ValidUntil: validUntil,
197195
},
198196
Validate: func(t *testing.T, eval *Evaluator) {
199-
withinLimits := eval.HasEnoughSeats(test.Probe)
197+
withinLimits := eval.hasEnoughSeats(test.Probe)
200198
if withinLimits != test.WithinLimits {
201-
t.Errorf("HasEnoughSeats did not behave as expected: lic=%d probe=%d expected=%v actual=%v", test.Licensed, test.Probe, test.WithinLimits, withinLimits)
199+
t.Errorf("hasEnoughSeats did not behave as expected: lic=%d probe=%d expected=%v actual=%v", test.Licensed, test.Probe, test.WithinLimits, withinLimits)
202200
}
203201
},
204202
Type: test.LicenseType,
@@ -212,31 +210,78 @@ func TestSeats(t *testing.T) {
212210
}
213211

214212
func TestFeatures(t *testing.T) {
213+
replicatedCommunity := ReplicatedLicenseTypeCommunity
214+
replicatedPaid := ReplicatedLicenseTypePaid
215+
215216
tests := []struct {
216-
Name string
217-
DefaultLicense bool
218-
Level LicenseLevel
219-
Features []Feature
220-
LicenseType LicenseType
217+
Name string
218+
DefaultLicense bool
219+
Level LicenseLevel
220+
Features []Feature
221+
LicenseType LicenseType
222+
UserCount int
223+
ReplicatedLicenseType *ReplicatedLicenseType
221224
}{
222-
{"Gitpod: no license", true, LicenseLevel(0), []Feature{FeaturePrebuild, FeatureAdminDashboard}, LicenseTypeGitpod},
223-
{"Gitpod: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod},
224-
{"Gitpod: enterprise license", false, LevelEnterprise, []Feature{
225+
{"Gitpod (in seats): no license", true, LicenseLevel(0), []Feature{
226+
FeatureAdminDashboard,
227+
FeatureSetTimeout,
228+
FeatureWorkspaceSharing,
229+
FeatureSnapshot,
230+
FeaturePrebuild,
231+
}, LicenseTypeGitpod, 10, nil},
232+
{"Gitpod (in seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats, nil},
233+
{"Gitpod (in seats): enterprise license", false, LevelEnterprise, []Feature{
225234
FeatureAdminDashboard,
226235
FeatureSetTimeout,
227236
FeatureWorkspaceSharing,
228237
FeatureSnapshot,
229238
FeaturePrebuild,
230-
}, LicenseTypeGitpod},
239+
}, LicenseTypeGitpod, seats, nil},
240+
241+
{"Gitpod (over seats): no license", true, LicenseLevel(0), []Feature{
242+
FeaturePrebuild,
243+
}, LicenseTypeGitpod, 11, nil},
244+
{"Gitpod (over seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats + 1, nil},
245+
{"Gitpod (over seats): enterprise license", false, LevelEnterprise, []Feature{}, LicenseTypeGitpod, seats + 1, nil},
231246

232-
{"Replicated: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated},
233-
{"Replicated: enterprise license", false, LevelEnterprise, []Feature{
247+
{"Replicated (in seats): invalid license level", false, LicenseLevel(666), []Feature{
234248
FeatureAdminDashboard,
235249
FeatureSetTimeout,
236250
FeatureWorkspaceSharing,
237251
FeatureSnapshot,
238252
FeaturePrebuild,
239-
}, LicenseTypeReplicated},
253+
}, LicenseTypeReplicated, seats, &replicatedPaid},
254+
{"Replicated (in seats): enterprise license", false, LevelEnterprise, []Feature{
255+
FeatureAdminDashboard,
256+
FeatureSetTimeout,
257+
FeatureWorkspaceSharing,
258+
FeatureSnapshot,
259+
FeaturePrebuild,
260+
}, LicenseTypeReplicated, seats, &replicatedPaid},
261+
262+
{"Replicated (over seats - no fallback): invalid license level", true, LicenseLevel(666), []Feature{
263+
FeatureAdminDashboard,
264+
FeatureSetTimeout,
265+
FeatureWorkspaceSharing,
266+
FeatureSnapshot,
267+
FeaturePrebuild,
268+
}, LicenseTypeReplicated, seats + 1, &replicatedPaid},
269+
{"Replicated (over seats - no fallback): enterprise license", true, LevelEnterprise, []Feature{
270+
FeatureAdminDashboard,
271+
FeatureSetTimeout,
272+
FeatureWorkspaceSharing,
273+
FeatureSnapshot,
274+
FeaturePrebuild,
275+
}, LicenseTypeReplicated, seats + 1, &replicatedPaid},
276+
277+
{"Replicated (over seats - fallback): invalid license level", false, LicenseLevel(666), []Feature{
278+
FeatureAdminDashboard,
279+
FeaturePrebuild,
280+
}, LicenseTypeReplicated, seats + 1, &replicatedCommunity},
281+
{"Replicated (over seats - fallback): enterprise license", false, LevelEnterprise, []Feature{
282+
FeatureAdminDashboard,
283+
FeaturePrebuild,
284+
}, LicenseTypeReplicated, seats + 1, &replicatedCommunity},
240285
}
241286

242287
for _, test := range tests {
@@ -251,8 +296,9 @@ func TestFeatures(t *testing.T) {
251296
lic = nil
252297
}
253298
lt := licenseTest{
254-
Name: test.Name,
255-
License: lic,
299+
Name: test.Name,
300+
License: lic,
301+
ReplicatedLicenseType: test.ReplicatedLicenseType,
256302
Validate: func(t *testing.T, eval *Evaluator) {
257303
unavailableFeatures := featureSet{}
258304
for f := range allowanceMap[LevelEnterprise].Features {
@@ -261,13 +307,13 @@ func TestFeatures(t *testing.T) {
261307
for _, f := range test.Features {
262308
delete(unavailableFeatures, f)
263309

264-
if !eval.Enabled(f) {
310+
if !eval.Enabled(f, test.UserCount) {
265311
t.Errorf("license does not enable %s, but should", f)
266312
}
267313
}
268314

269315
for f := range unavailableFeatures {
270-
if eval.Enabled(f) {
316+
if eval.Enabled(f, test.UserCount) {
271317
t.Errorf("license not enables %s, but shouldn't", f)
272318
}
273319
}

components/licensor/ee/pkg/licensor/replicated.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,25 @@ type replicatedFields struct {
2323
Value interface{} `json:"value"` // This is of type "fieldType"
2424
}
2525

26+
type ReplicatedLicenseType string
27+
28+
// variable names are what Replicated calls them in the vendor portal
29+
const (
30+
ReplicatedLicenseTypeCommunity ReplicatedLicenseType = "community"
31+
ReplicatedLicenseTypeDevelopment ReplicatedLicenseType = "dev"
32+
ReplicatedLicenseTypePaid ReplicatedLicenseType = "prod"
33+
ReplicatedLicenseTypeTrial ReplicatedLicenseType = "trial"
34+
)
35+
2636
// replicatedLicensePayload exists to convert the JSON structure to a LicensePayload
2737
type replicatedLicensePayload struct {
28-
LicenseID string `json:"license_id"`
29-
InstallationID string `json:"installation_id"`
30-
Assignee string `json:"assignee"`
31-
ReleaseChannel string `json:"release_channel"`
32-
LicenseType string `json:"license_type"`
33-
ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires
34-
Fields []replicatedFields `json:"fields"`
38+
LicenseID string `json:"license_id"`
39+
InstallationID string `json:"installation_id"`
40+
Assignee string `json:"assignee"`
41+
ReleaseChannel string `json:"release_channel"`
42+
LicenseType ReplicatedLicenseType `json:"license_type"`
43+
ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires
44+
Fields []replicatedFields `json:"fields"`
3545
}
3646

3747
type ReplicatedEvaluator struct {
@@ -90,7 +100,8 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator)
90100
}
91101

92102
lic := LicensePayload{
93-
ID: replicatedPayload.LicenseID,
103+
ID: replicatedPayload.LicenseID,
104+
Level: LevelEnterprise,
94105
}
95106

96107
// Search for the fields
@@ -99,9 +110,6 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator)
99110
case "domain":
100111
lic.Domain = i.Value.(string)
101112

102-
case "levelId":
103-
lic.Level = LicenseLevel(i.Value.(float64))
104-
105113
case "seats":
106114
lic.Seats = int(i.Value.(float64))
107115
}
@@ -120,7 +128,8 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator)
120128
}
121129

122130
return &Evaluator{
123-
lic: lic,
131+
lic: lic,
132+
allowFallback: replicatedPayload.LicenseType == ReplicatedLicenseTypeCommunity, // Only community licenses are allowed to fallback
124133
}
125134
}
126135

components/licensor/typescript/ee/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616

1717
var (
1818
instances map[int]*licensor.Evaluator = make(map[int]*licensor.Evaluator)
19-
nextID int = 1
19+
nextID int = 1
2020
)
2121

2222
// Init initializes the global license evaluator from an environment variable
@@ -49,13 +49,13 @@ func Validate(id int) (msg *C.char, valid bool) {
4949

5050
// Enabled returns true if a license enables a feature
5151
//export Enabled
52-
func Enabled(id int, feature *C.char) (enabled, ok bool) {
52+
func Enabled(id int, feature *C.char, seats int) (enabled, ok bool) {
5353
e, ok := instances[id]
5454
if !ok {
5555
return
5656
}
5757

58-
return e.Enabled(licensor.Feature(C.GoString(feature))), true
58+
return e.Enabled(licensor.Feature(C.GoString(feature)), seats), true
5959
}
6060

6161
// HasEnoughSeats returns true if the license supports at least the given number of seats.

components/licensor/typescript/ee/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export class LicenseEvaluator {
4949
return { msg: v.msg, valid: false };
5050
}
5151

52-
public isEnabled(feature: Feature): boolean {
53-
return isEnabled(this.instanceID, feature);
52+
public isEnabled(feature: Feature, seats: number): boolean {
53+
return isEnabled(this.instanceID, feature, seats);
5454
}
5555

5656
public hasEnoughSeats(seats: number): boolean {

0 commit comments

Comments
 (0)