Skip to content

Commit

Permalink
[licensor]: introduce concept of a fallback license with limited feat…
Browse files Browse the repository at this point in the history
…ures

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.
  • Loading branch information
Simon Emms committed Mar 7, 2022
1 parent 967a303 commit 96104e4
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 35 deletions.
3 changes: 2 additions & 1 deletion components/licensor/ee/pkg/licensor/gitpod.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) {
}

return &Evaluator{
lic: lic.LicensePayload,
lic: lic.LicensePayload,
noFallback: true,
}
}
47 changes: 40 additions & 7 deletions components/licensor/ee/pkg/licensor/licensor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand 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.
Expand All @@ -158,24 +168,47 @@ 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]
fmt.Println(e.noFallback)

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
}

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
Expand Down
47 changes: 36 additions & 11 deletions components/licensor/ee/pkg/licensor/licensor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,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,
Expand All @@ -218,25 +218,50 @@ 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},
}, 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: invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated},
{"Replicated: enterprise license", false, LevelEnterprise, []Feature{
{"Replicated (in seats): invalid license level", false, LicenseLevel(666), []Feature{}, LicenseTypeReplicated, seats},
{"Replicated (in seats): enterprise license", false, LevelEnterprise, []Feature{
FeatureAdminDashboard,
FeatureSetTimeout,
FeatureWorkspaceSharing,
FeatureSnapshot,
FeaturePrebuild,
}, LicenseTypeReplicated},
}, LicenseTypeReplicated, seats},

// @todo(sje): add Replicated tests with no fallback

{"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 {
Expand All @@ -261,13 +286,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)
}
}
Expand Down
27 changes: 19 additions & 8 deletions components/licensor/ee/pkg/licensor/replicated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -120,7 +130,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
}
}

Expand Down
6 changes: 3 additions & 3 deletions components/licensor/typescript/ee/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions components/licensor/typescript/ee/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions components/licensor/typescript/ee/src/module.cc
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ void EnabledM(const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();

if (args.Length() < 2) {
if (args.Length() < 3) {
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked()));
return;
}
Expand All @@ -108,6 +108,10 @@ void EnabledM(const FunctionCallbackInfo<Value> &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<int>(rid);
Expand All @@ -116,8 +120,15 @@ void EnabledM(const FunctionCallbackInfo<Value> &args) {
const char* cstr = ToCString(str);
char* featurestr = const_cast<char *>(cstr);

double rseats = args[2]->NumberValue(context).FromMaybe(-1);
int seats = static_cast<int>(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()));
Expand Down
2 changes: 1 addition & 1 deletion components/licensor/typescript/ee/src/nativemodule.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

0 comments on commit 96104e4

Please sign in to comment.