Skip to content

Commit 15da68d

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 f8ad188 commit 15da68d

File tree

8 files changed

+116
-35
lines changed

8 files changed

+116
-35
lines changed

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

+2-1
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+
noFallback: true,
6566
}
6667
}

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

+40-7
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+
noFallback 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,47 @@ 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+
fmt.Println(e.noFallback)
177+
178+
var ok bool
179+
if e.hasEnoughSeats(seats) {
180+
// License has enough seats available - evaluate this license
181+
_, ok = e.lic.Level.allowance().Features[feature]
182+
} else if !e.noFallback {
183+
// License has run out of seats - use the fallback license
184+
_, ok = fallbackLicense.Level.allowance().Features[feature]
185+
}
186+
167187
return ok
168188
}
169189

170-
// HasEnoughSeats returns true if the license supports at least the give amount of seats
171-
func (e *Evaluator) HasEnoughSeats(seats int) bool {
190+
// hasEnoughSeats returns true if the license supports at least the give amount of seats
191+
func (e *Evaluator) hasEnoughSeats(seats int) bool {
172192
if e.invalid != "" {
173193
return false
174194
}
175195

176196
return e.lic.Seats == 0 || seats <= e.lic.Seats
177197
}
178198

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

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

+36-11
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,9 @@ func TestSeats(t *testing.T) {
196196
ValidUntil: validUntil,
197197
},
198198
Validate: func(t *testing.T, eval *Evaluator) {
199-
withinLimits := eval.HasEnoughSeats(test.Probe)
199+
withinLimits := eval.hasEnoughSeats(test.Probe)
200200
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)
201+
t.Errorf("hasEnoughSeats did not behave as expected: lic=%d probe=%d expected=%v actual=%v", test.Licensed, test.Probe, test.WithinLimits, withinLimits)
202202
}
203203
},
204204
Type: test.LicenseType,
@@ -218,25 +218,50 @@ func TestFeatures(t *testing.T) {
218218
Level LicenseLevel
219219
Features []Feature
220220
LicenseType LicenseType
221+
UserCount int
221222
}{
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{
223+
{"Gitpod (in seats): no license", true, LicenseLevel(0), []Feature{
225224
FeatureAdminDashboard,
226225
FeatureSetTimeout,
227226
FeatureWorkspaceSharing,
228227
FeatureSnapshot,
229228
FeaturePrebuild,
230-
}, LicenseTypeGitpod},
229+
}, LicenseTypeGitpod, 10},
230+
{"Gitpod (in seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats},
231+
{"Gitpod (in seats): enterprise license", false, LevelEnterprise, []Feature{
232+
FeatureAdminDashboard,
233+
FeatureSetTimeout,
234+
FeatureWorkspaceSharing,
235+
FeatureSnapshot,
236+
FeaturePrebuild,
237+
}, LicenseTypeGitpod, seats},
238+
239+
{"Gitpod (over seats): no license", true, LicenseLevel(0), []Feature{
240+
FeatureAdminDashboard,
241+
FeaturePrebuild,
242+
}, LicenseTypeGitpod, 11},
243+
{"Gitpod (over seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats + 1},
244+
{"Gitpod (over seats): enterprise license", false, LevelEnterprise, []Feature{}, LicenseTypeGitpod, seats + 1},
231245

232-
{"Replicated: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated},
233-
{"Replicated: enterprise license", false, LevelEnterprise, []Feature{
246+
{"Replicated (in seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated, seats},
247+
{"Replicated (in seats): enterprise license", false, LevelEnterprise, []Feature{
234248
FeatureAdminDashboard,
235249
FeatureSetTimeout,
236250
FeatureWorkspaceSharing,
237251
FeatureSnapshot,
238252
FeaturePrebuild,
239-
}, LicenseTypeReplicated},
253+
}, LicenseTypeReplicated, seats},
254+
255+
// @todo(sje): add Replicated tests with no fallback
256+
257+
{"Replicated (over seats - fallback): invalid license level", false, LicenseLevel(666), []Feature{
258+
FeatureAdminDashboard,
259+
FeaturePrebuild,
260+
}, LicenseTypeReplicated, seats + 1},
261+
{"Replicated (over seats - fallback): enterprise license", false, LevelEnterprise, []Feature{
262+
FeatureAdminDashboard,
263+
FeaturePrebuild,
264+
}, LicenseTypeReplicated, seats + 1},
240265
}
241266

242267
for _, test := range tests {
@@ -261,13 +286,13 @@ func TestFeatures(t *testing.T) {
261286
for _, f := range test.Features {
262287
delete(unavailableFeatures, f)
263288

264-
if !eval.Enabled(f) {
289+
if !eval.Enabled(f, test.UserCount) {
265290
t.Errorf("license does not enable %s, but should", f)
266291
}
267292
}
268293

269294
for f := range unavailableFeatures {
270-
if eval.Enabled(f) {
295+
if eval.Enabled(f, test.UserCount) {
271296
t.Errorf("license not enables %s, but shouldn't", f)
272297
}
273298
}

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

+19-8
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 {
@@ -120,7 +130,8 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator)
120130
}
121131

122132
return &Evaluator{
123-
lic: lic,
133+
lic: lic,
134+
noFallback: replicatedPayload.LicenseType == ReplicatedLicenseTypePaid, // Don't allow paid license to use the fallback license
124135
}
125136
}
126137

components/licensor/typescript/ee/main.go

+3-3
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

+2-2
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 {

components/licensor/typescript/ee/src/module.cc

+13-2
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ void EnabledM(const FunctionCallbackInfo<Value> &args) {
9595
Isolate *isolate = args.GetIsolate();
9696
Local<Context> context = isolate->GetCurrentContext();
9797

98-
if (args.Length() < 2) {
98+
if (args.Length() < 3) {
9999
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked()));
100100
return;
101101
}
@@ -108,6 +108,10 @@ void EnabledM(const FunctionCallbackInfo<Value> &args) {
108108
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 1 must be a string").ToLocalChecked()));
109109
return;
110110
}
111+
if (!args[2]->IsNumber() || args[2]->IsUndefined()) {
112+
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 2 must be a number").ToLocalChecked()));
113+
return;
114+
}
111115

112116
double rid = args[0]->NumberValue(context).FromMaybe(0);
113117
int id = static_cast<int>(rid);
@@ -116,8 +120,15 @@ void EnabledM(const FunctionCallbackInfo<Value> &args) {
116120
const char* cstr = ToCString(str);
117121
char* featurestr = const_cast<char *>(cstr);
118122

123+
double rseats = args[2]->NumberValue(context).FromMaybe(-1);
124+
int seats = static_cast<int>(rseats);
125+
if (seats < 0) {
126+
isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "cannot convert number of seats").ToLocalChecked()));
127+
return;
128+
}
129+
119130
// Call exported Go function, which returns a C string
120-
Enabled_return r = Enabled(id, featurestr);
131+
Enabled_return r = Enabled(id, featurestr, seats);
121132

122133
if (!r.r1) {
123134
isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "invalid instance ID").ToLocalChecked()));

components/licensor/typescript/ee/src/nativemodule.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type Instance = number;
99

1010
export function init(key: string, domain: string): Instance;
1111
export function validate(id: Instance): { msg: string, valid: boolean };
12-
export function isEnabled(id: Instance, feature: Feature): boolean;
12+
export function isEnabled(id: Instance, feature: Feature, seats: int): boolean;
1313
export function hasEnoughSeats(id: Instance, seats: int): boolean;
1414
export function inspect(id: Instance): string;
1515
export function dispose(id: Instance);

0 commit comments

Comments
 (0)