From d6de10772c61d09f4a6892e16eb4e7004fce864f Mon Sep 17 00:00:00 2001 From: Simon Emms Date: Fri, 4 Mar 2022 11:18:02 +0000 Subject: [PATCH] [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. --- components/licensor/ee/pkg/licensor/gitpod.go | 3 +- .../licensor/ee/pkg/licensor/licensor.go | 45 +++++++++-- .../licensor/ee/pkg/licensor/licensor_test.go | 75 ++++++++++++++----- .../licensor/ee/pkg/licensor/replicated.go | 33 +++++--- components/licensor/typescript/ee/main.go | 6 +- .../licensor/typescript/ee/src/index.ts | 4 +- .../licensor/typescript/ee/src/module.cc | 15 +++- .../typescript/ee/src/nativemodule.d.ts | 2 +- 8 files changed, 135 insertions(+), 48 deletions(-) diff --git a/components/licensor/ee/pkg/licensor/gitpod.go b/components/licensor/ee/pkg/licensor/gitpod.go index fe3bc76fc72818..2be80ccd80b27f 100644 --- a/components/licensor/ee/pkg/licensor/gitpod.go +++ b/components/licensor/ee/pkg/licensor/gitpod.go @@ -61,6 +61,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) { } return &Evaluator{ - lic: lic.LicensePayload, + lic: lic.LicensePayload, + noFallback: true, } } diff --git a/components/licensor/ee/pkg/licensor/licensor.go b/components/licensor/ee/pkg/licensor/licensor.go index d0a447fd70e9b4..d2e6be714558ac 100644 --- a/components/licensor/ee/pkg/licensor/licensor.go +++ b/components/licensor/ee/pkg/licensor/licensor.go @@ -117,9 +117,18 @@ func (lvl LicenseLevel) allowance() allowance { return a } +// Fallback license is used when the instance exceeds the number of licenses - it allows limited access +var fallbackLicense = LicensePayload{ + ID: "fallback-license", + Level: LevelTeam, + Seats: 0, + // Domain, ValidUntil are free for all +} + +// Default license is used when no valid license is given - it allows full access up to 10 users var defaultLicense = LicensePayload{ ID: "default-license", - Level: LevelTeam, + Level: LevelEnterprise, Seats: 10, // Domain, ValidUntil are free for all } @@ -144,8 +153,9 @@ func matchesDomain(pattern, domain string) bool { // Evaluator determines what a license allows for type Evaluator struct { - invalid string - lic LicensePayload + invalid string + noFallback bool // Paid licenses cannot fallback and prevent additional signups + lic LicensePayload } // Validate returns false if the license isn't valid and a message explaining why that is. @@ -158,17 +168,25 @@ func (e *Evaluator) Validate() (msg string, valid bool) { } // Enabled determines if a feature is enabled by the license -func (e *Evaluator) Enabled(feature Feature) bool { +func (e *Evaluator) Enabled(feature Feature, seats int) bool { if e.invalid != "" { return false } - _, ok := e.lic.Level.allowance().Features[feature] + var ok bool + if e.hasEnoughSeats(seats) { + // License has enough seats available - evaluate this license + _, ok = e.lic.Level.allowance().Features[feature] + } else if !e.noFallback { + // License has run out of seats - use the fallback license + _, ok = fallbackLicense.Level.allowance().Features[feature] + } + return ok } -// HasEnoughSeats returns true if the license supports at least the give amount of seats -func (e *Evaluator) HasEnoughSeats(seats int) bool { +// hasEnoughSeats returns true if the license supports at least the give amount of seats +func (e *Evaluator) hasEnoughSeats(seats int) bool { if e.invalid != "" { return false } @@ -176,6 +194,19 @@ func (e *Evaluator) HasEnoughSeats(seats int) bool { return e.lic.Seats == 0 || seats <= e.lic.Seats } +// HasEnoughSeats is the public method to hasEnoughSeats. Will use fallback license if allowable +func (e *Evaluator) HasEnoughSeats(seats int) bool { + if e.invalid != "" { + return false + } + + if e.noFallback { + return e.hasEnoughSeats(seats) + } + // There is always more space if can use a fallback license + return true +} + // Inspect returns the license information this evaluator holds. // This function is intended for transparency/debugging purposes only and must // never be used to determine feature eligibility under a license. All code making diff --git a/components/licensor/ee/pkg/licensor/licensor_test.go b/components/licensor/ee/pkg/licensor/licensor_test.go index febeee139906f8..a6c9c14d97fe07 100644 --- a/components/licensor/ee/pkg/licensor/licensor_test.go +++ b/components/licensor/ee/pkg/licensor/licensor_test.go @@ -96,15 +96,6 @@ func (test *licenseTest) Run(t *testing.T) { return domain }(), }, - { - Field: "levelId", - Value: func() LicenseLevel { - if test.License != nil { - return test.License.Level - } - return LevelTeam - }(), - }, { Field: "seats", Value: func() int { @@ -196,9 +187,9 @@ func TestSeats(t *testing.T) { ValidUntil: validUntil, }, Validate: func(t *testing.T, eval *Evaluator) { - withinLimits := eval.HasEnoughSeats(test.Probe) + withinLimits := eval.hasEnoughSeats(test.Probe) if withinLimits != test.WithinLimits { - t.Errorf("HasEnoughSeats did not behave as expected: lic=%d probe=%d expected=%v actual=%v", test.Licensed, test.Probe, test.WithinLimits, withinLimits) + t.Errorf("hasEnoughSeats did not behave as expected: lic=%d probe=%d expected=%v actual=%v", test.Licensed, test.Probe, test.WithinLimits, withinLimits) } }, Type: test.LicenseType, @@ -218,25 +209,69 @@ func TestFeatures(t *testing.T) { Level LicenseLevel Features []Feature LicenseType LicenseType + UserCount int }{ - {"Gitpod: no license", true, LicenseLevel(0), []Feature{FeaturePrebuild, FeatureAdminDashboard}, LicenseTypeGitpod}, - {"Gitpod: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod}, - {"Gitpod: enterprise license", false, LevelEnterprise, []Feature{ + {"Gitpod (in seats): no license", true, LicenseLevel(0), []Feature{ + FeatureAdminDashboard, + FeatureSetTimeout, + FeatureWorkspaceSharing, + FeatureSnapshot, + FeaturePrebuild, + }, LicenseTypeGitpod, 10}, + {"Gitpod (in seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats}, + {"Gitpod (in seats): enterprise license", false, LevelEnterprise, []Feature{ + FeatureAdminDashboard, + FeatureSetTimeout, + FeatureWorkspaceSharing, + FeatureSnapshot, + FeaturePrebuild, + }, LicenseTypeGitpod, seats}, + + {"Gitpod (over seats): no license", true, LicenseLevel(0), []Feature{ + FeatureAdminDashboard, + FeaturePrebuild, + }, LicenseTypeGitpod, 11}, + {"Gitpod (over seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeGitpod, seats + 1}, + {"Gitpod (over seats): enterprise license", false, LevelEnterprise, []Feature{}, LicenseTypeGitpod, seats + 1}, + + {"Replicated (in seats): invalid license level", false, LicenseLevel(666), []Feature{ FeatureAdminDashboard, FeatureSetTimeout, FeatureWorkspaceSharing, FeatureSnapshot, FeaturePrebuild, - }, LicenseTypeGitpod}, + }, LicenseTypeReplicated, seats}, + {"Replicated (in seats): enterprise license", false, LevelEnterprise, []Feature{ + FeatureAdminDashboard, + FeatureSetTimeout, + FeatureWorkspaceSharing, + FeatureSnapshot, + FeaturePrebuild, + }, LicenseTypeReplicated, seats}, - {"Replicated: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated}, - {"Replicated: enterprise license", false, LevelEnterprise, []Feature{ + {"Replicated (over seats - no fallback): invalid license level", true, LicenseLevel(666), []Feature{ FeatureAdminDashboard, FeatureSetTimeout, FeatureWorkspaceSharing, FeatureSnapshot, FeaturePrebuild, - }, LicenseTypeReplicated}, + }, LicenseTypeReplicated, seats + 1}, + {"Replicated (over seats - no fallback): enterprise license", true, LevelEnterprise, []Feature{ + FeatureAdminDashboard, + FeatureSetTimeout, + FeatureWorkspaceSharing, + FeatureSnapshot, + FeaturePrebuild, + }, LicenseTypeReplicated, seats + 1}, + + {"Replicated (over seats - fallback): invalid license level", false, LicenseLevel(666), []Feature{ + FeatureAdminDashboard, + FeaturePrebuild, + }, LicenseTypeReplicated, seats + 1}, + {"Replicated (over seats - fallback): enterprise license", false, LevelEnterprise, []Feature{ + FeatureAdminDashboard, + FeaturePrebuild, + }, LicenseTypeReplicated, seats + 1}, } for _, test := range tests { @@ -261,13 +296,13 @@ func TestFeatures(t *testing.T) { for _, f := range test.Features { delete(unavailableFeatures, f) - if !eval.Enabled(f) { + if !eval.Enabled(f, test.UserCount) { t.Errorf("license does not enable %s, but should", f) } } for f := range unavailableFeatures { - if eval.Enabled(f) { + if eval.Enabled(f, test.UserCount) { t.Errorf("license not enables %s, but shouldn't", f) } } diff --git a/components/licensor/ee/pkg/licensor/replicated.go b/components/licensor/ee/pkg/licensor/replicated.go index 5a74b6eeefa43c..9ae080007321cc 100644 --- a/components/licensor/ee/pkg/licensor/replicated.go +++ b/components/licensor/ee/pkg/licensor/replicated.go @@ -23,15 +23,25 @@ type replicatedFields struct { Value interface{} `json:"value"` // This is of type "fieldType" } +type ReplicatedLicenseType string + +// variable names are what Replicated calls them in the vendor portal +const ( + ReplicatedLicenseTypeCommunity ReplicatedLicenseType = "community" + ReplicatedLicenseTypeDevelopment ReplicatedLicenseType = "dev" + ReplicatedLicenseTypePaid ReplicatedLicenseType = "prod" + ReplicatedLicenseTypeTrial ReplicatedLicenseType = "trial" +) + // replicatedLicensePayload exists to convert the JSON structure to a LicensePayload type replicatedLicensePayload struct { - LicenseID string `json:"license_id"` - InstallationID string `json:"installation_id"` - Assignee string `json:"assignee"` - ReleaseChannel string `json:"release_channel"` - LicenseType string `json:"license_type"` - ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires - Fields []replicatedFields `json:"fields"` + LicenseID string `json:"license_id"` + InstallationID string `json:"installation_id"` + Assignee string `json:"assignee"` + ReleaseChannel string `json:"release_channel"` + LicenseType ReplicatedLicenseType `json:"license_type"` + ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires + Fields []replicatedFields `json:"fields"` } type ReplicatedEvaluator struct { @@ -90,7 +100,8 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator) } lic := LicensePayload{ - ID: replicatedPayload.LicenseID, + ID: replicatedPayload.LicenseID, + Level: LevelEnterprise, } // Search for the fields @@ -99,9 +110,6 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator) case "domain": lic.Domain = i.Value.(string) - case "levelId": - lic.Level = LicenseLevel(i.Value.(float64)) - case "seats": lic.Seats = int(i.Value.(float64)) } @@ -120,7 +128,8 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator) } return &Evaluator{ - lic: lic, + lic: lic, + noFallback: replicatedPayload.LicenseType == ReplicatedLicenseTypePaid, // Don't allow paid license to use the fallback license } } diff --git a/components/licensor/typescript/ee/main.go b/components/licensor/typescript/ee/main.go index 02bab5adbe989c..a5f81a117fc8ad 100644 --- a/components/licensor/typescript/ee/main.go +++ b/components/licensor/typescript/ee/main.go @@ -16,7 +16,7 @@ import ( var ( instances map[int]*licensor.Evaluator = make(map[int]*licensor.Evaluator) - nextID int = 1 + nextID int = 1 ) // Init initializes the global license evaluator from an environment variable @@ -49,13 +49,13 @@ func Validate(id int) (msg *C.char, valid bool) { // Enabled returns true if a license enables a feature //export Enabled -func Enabled(id int, feature *C.char) (enabled, ok bool) { +func Enabled(id int, feature *C.char, seats int) (enabled, ok bool) { e, ok := instances[id] if !ok { return } - return e.Enabled(licensor.Feature(C.GoString(feature))), true + return e.Enabled(licensor.Feature(C.GoString(feature)), seats), true } // HasEnoughSeats returns true if the license supports at least the given number of seats. diff --git a/components/licensor/typescript/ee/src/index.ts b/components/licensor/typescript/ee/src/index.ts index aa0350c348811a..d4b24bc083bb79 100644 --- a/components/licensor/typescript/ee/src/index.ts +++ b/components/licensor/typescript/ee/src/index.ts @@ -49,8 +49,8 @@ export class LicenseEvaluator { return { msg: v.msg, valid: false }; } - public isEnabled(feature: Feature): boolean { - return isEnabled(this.instanceID, feature); + public isEnabled(feature: Feature, seats: number): boolean { + return isEnabled(this.instanceID, feature, seats); } public hasEnoughSeats(seats: number): boolean { diff --git a/components/licensor/typescript/ee/src/module.cc b/components/licensor/typescript/ee/src/module.cc index 1fc61a1ef1f479..3787bcfc5bbbde 100644 --- a/components/licensor/typescript/ee/src/module.cc +++ b/components/licensor/typescript/ee/src/module.cc @@ -95,7 +95,7 @@ void EnabledM(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); - if (args.Length() < 2) { + if (args.Length() < 3) { isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked())); return; } @@ -108,6 +108,10 @@ void EnabledM(const FunctionCallbackInfo &args) { isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 1 must be a string").ToLocalChecked())); return; } + if (!args[2]->IsNumber() || args[2]->IsUndefined()) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 2 must be a number").ToLocalChecked())); + return; + } double rid = args[0]->NumberValue(context).FromMaybe(0); int id = static_cast(rid); @@ -116,8 +120,15 @@ void EnabledM(const FunctionCallbackInfo &args) { const char* cstr = ToCString(str); char* featurestr = const_cast(cstr); + double rseats = args[2]->NumberValue(context).FromMaybe(-1); + int seats = static_cast(rseats); + if (seats < 0) { + isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "cannot convert number of seats").ToLocalChecked())); + return; + } + // Call exported Go function, which returns a C string - Enabled_return r = Enabled(id, featurestr); + Enabled_return r = Enabled(id, featurestr, seats); if (!r.r1) { isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "invalid instance ID").ToLocalChecked())); diff --git a/components/licensor/typescript/ee/src/nativemodule.d.ts b/components/licensor/typescript/ee/src/nativemodule.d.ts index 6fbdc47d658a4a..ae90944cbcbffe 100644 --- a/components/licensor/typescript/ee/src/nativemodule.d.ts +++ b/components/licensor/typescript/ee/src/nativemodule.d.ts @@ -9,7 +9,7 @@ export type Instance = number; export function init(key: string, domain: string): Instance; export function validate(id: Instance): { msg: string, valid: boolean }; -export function isEnabled(id: Instance, feature: Feature): boolean; +export function isEnabled(id: Instance, feature: Feature, seats: int): boolean; export function hasEnoughSeats(id: Instance, seats: int): boolean; export function inspect(id: Instance): string; export function dispose(id: Instance);