feat: implement premium vs enterprise licenses (#13907)

* feat: implement premium vs enterprise licenses

Implement different sets of licensed features.
This commit is contained in:
Steven Masley
2024-07-24 07:07:59 -10:00
committed by GitHub
parent 0d9615b4fd
commit 15fda232b7
8 changed files with 976 additions and 155 deletions

View File

@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -34,6 +35,21 @@ const (
EntitlementNotEntitled Entitlement = "not_entitled" EntitlementNotEntitled Entitlement = "not_entitled"
) )
// Weight converts the enum types to a numerical value for easier
// comparisons. Easier than sets of if statements.
func (e Entitlement) Weight() int {
switch e {
case EntitlementEntitled:
return 2
case EntitlementGracePeriod:
return 1
case EntitlementNotEntitled:
return -1
default:
return -2
}
}
// FeatureName represents the internal name of a feature. // FeatureName represents the internal name of a feature.
// To add a new feature, add it to this set of enums as well as the FeatureNames // To add a new feature, add it to this set of enums as well as the FeatureNames
// array below. // array below.
@ -95,8 +111,11 @@ func (n FeatureName) Humanize() string {
} }
// AlwaysEnable returns if the feature is always enabled if entitled. // AlwaysEnable returns if the feature is always enabled if entitled.
// Warning: We don't know if we need this functionality. // This is required because some features are only enabled if they are entitled
// This method may disappear at any time. // and not required.
// E.g: "multiple-organizations" is disabled by default in AGPL and enterprise
// deployments. This feature should only be enabled for premium deployments
// when it is entitled.
func (n FeatureName) AlwaysEnable() bool { func (n FeatureName) AlwaysEnable() bool {
return map[FeatureName]bool{ return map[FeatureName]bool{
FeatureMultipleExternalAuth: true, FeatureMultipleExternalAuth: true,
@ -105,9 +124,54 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureWorkspaceBatchActions: true, FeatureWorkspaceBatchActions: true,
FeatureHighAvailability: true, FeatureHighAvailability: true,
FeatureCustomRoles: true, FeatureCustomRoles: true,
FeatureMultipleOrganizations: true,
}[n] }[n]
} }
// FeatureSet represents a grouping of features. Rather than manually
// assigning features al-la-carte when making a license, a set can be specified.
// Sets are dynamic in the sense a feature can be added to a set, granting the
// feature to existing licenses out in the wild.
// If features were granted al-la-carte, we would need to reissue the existing
// old licenses to include the new feature.
type FeatureSet string
const (
FeatureSetNone FeatureSet = ""
FeatureSetEnterprise FeatureSet = "enterprise"
FeatureSetPremium FeatureSet = "premium"
)
func (set FeatureSet) Features() []FeatureName {
switch FeatureSet(strings.ToLower(string(set))) {
case FeatureSetEnterprise:
// Enterprise is the set 'AllFeatures' minus some select features.
// Copy the list of all features
enterpriseFeatures := make([]FeatureName, len(FeatureNames))
copy(enterpriseFeatures, FeatureNames)
// Remove the selection
enterpriseFeatures = slices.DeleteFunc(enterpriseFeatures, func(f FeatureName) bool {
switch f {
// Add all features that should be excluded in the Enterprise feature set.
case FeatureMultipleOrganizations:
return true
default:
return false
}
})
return enterpriseFeatures
case FeatureSetPremium:
premiumFeatures := make([]FeatureName, len(FeatureNames))
copy(premiumFeatures, FeatureNames)
// FeatureSetPremium is just all features.
return premiumFeatures
}
// By default, return an empty set.
return []FeatureName{}
}
type Feature struct { type Feature struct {
Entitlement Entitlement `json:"entitlement"` Entitlement Entitlement `json:"entitlement"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@ -115,6 +179,89 @@ type Feature struct {
Actual *int64 `json:"actual,omitempty"` Actual *int64 `json:"actual,omitempty"`
} }
// Compare compares two features and returns an integer representing
// if the first feature (f) is greater than, equal to, or less than the second
// feature (b). "Greater than" means the first feature has more functionality
// than the second feature. It is assumed the features are for the same FeatureName.
//
// A feature is considered greater than another feature if:
// 1. Graceful & capable > Entitled & not capable
// 2. The entitlement is greater
// 3. The limit is greater
// 4. Enabled is greater than disabled
// 5. The actual is greater
func (f Feature) Compare(b Feature) int {
if !f.Capable() || !b.Capable() {
// If either is incapable, then it is possible a grace period
// feature can be "greater" than an entitled.
// If either is "NotEntitled" then we can defer to a strict entitlement
// check.
if f.Entitlement.Weight() >= 0 && b.Entitlement.Weight() >= 0 {
if f.Capable() && !b.Capable() {
return 1
}
if b.Capable() && !f.Capable() {
return -1
}
}
}
// Strict entitlement check. Higher is better
entitlementDifference := f.Entitlement.Weight() - b.Entitlement.Weight()
if entitlementDifference != 0 {
return entitlementDifference
}
// If the entitlement is the same, then we can compare the limits.
if f.Limit == nil && b.Limit != nil {
return -1
}
if f.Limit != nil && b.Limit == nil {
return 1
}
if f.Limit != nil && b.Limit != nil {
difference := *f.Limit - *b.Limit
if difference != 0 {
return int(difference)
}
}
// Enabled is better than disabled.
if f.Enabled && !b.Enabled {
return 1
}
if !f.Enabled && b.Enabled {
return -1
}
// Higher actual is better
if f.Actual == nil && b.Actual != nil {
return -1
}
if f.Actual != nil && b.Actual == nil {
return 1
}
if f.Actual != nil && b.Actual != nil {
difference := *f.Actual - *b.Actual
if difference != 0 {
return int(difference)
}
}
return 0
}
// Capable is a helper function that returns if a given feature has a limit
// that is greater than or equal to the actual.
// If this condition is not true, then the feature is not capable of being used
// since the limit is not high enough.
func (f Feature) Capable() bool {
if f.Limit != nil && f.Actual != nil {
return *f.Limit >= *f.Actual
}
return true
}
type Entitlements struct { type Entitlements struct {
Features map[FeatureName]Feature `json:"features"` Features map[FeatureName]Feature `json:"features"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
@ -125,6 +272,29 @@ type Entitlements struct {
RefreshedAt time.Time `json:"refreshed_at" format:"date-time"` RefreshedAt time.Time `json:"refreshed_at" format:"date-time"`
} }
// AddFeature will add the feature to the entitlements iff it expands
// the set of features granted by the entitlements. If it does not, it will
// be ignored and the existing feature with the same name will remain.
//
// All features should be added as atomic items, and not merged in any way.
// Merging entitlements could lead to unexpected behavior, like a larger user
// limit in grace period merging with a smaller one in an "entitled" state. This
// could lead to the larger limit being extended as "entitled", which is not correct.
func (e *Entitlements) AddFeature(name FeatureName, add Feature) {
existing, ok := e.Features[name]
if !ok {
e.Features[name] = add
return
}
// Compare the features, keep the one that is "better"
comparison := add.Compare(existing)
if comparison > 0 {
e.Features[name] = add
return
}
}
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil) res, err := c.Request(ctx, http.MethodGet, "/api/v2/entitlements", nil)
if err != nil { if err != nil {

View File

@ -3,15 +3,18 @@ package codersdk_test
import ( import (
"bytes" "bytes"
"embed" "embed"
"encoding/json"
"fmt" "fmt"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent" "github.com/coder/serpent"
) )
@ -379,3 +382,182 @@ func TestExternalAuthYAMLConfig(t *testing.T) {
output := strings.Replace(out.String(), "value:", "externalAuthProviders:", 1) output := strings.Replace(out.String(), "value:", "externalAuthProviders:", 1)
require.Equal(t, inputYAML, output, "re-marshaled is the same as input") require.Equal(t, inputYAML, output, "re-marshaled is the same as input")
} }
func TestFeatureComparison(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
A codersdk.Feature
B codersdk.Feature
Expected int
}{
{
Name: "Empty",
Expected: 0,
},
// Entitlement check
// Entitled
{
Name: "EntitledVsGracePeriod",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
Expected: 1,
},
{
Name: "EntitledVsGracePeriodLimits",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
// Entitled should still win here
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: ptr.Ref[int64](100), Actual: ptr.Ref[int64](50)},
Expected: 1,
},
{
Name: "EntitledVsNotEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
Expected: 3,
},
{
Name: "EntitledVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: ""},
Expected: 4,
},
// GracePeriod
{
Name: "GracefulVsNotEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
Expected: 2,
},
{
Name: "GracefulVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
B: codersdk.Feature{Entitlement: ""},
Expected: 3,
},
// NotEntitled
{
Name: "NotEntitledVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
B: codersdk.Feature{Entitlement: ""},
Expected: 1,
},
// --
{
Name: "EntitledVsGracePeriodCapable",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref[int64](100), Actual: ptr.Ref[int64](200)},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: ptr.Ref[int64](300), Actual: ptr.Ref[int64](200)},
Expected: -1,
},
// UserLimits
{
// Tests an exceeded limit that is entitled vs a graceful limit that
// is not exceeded. This is the edge case that we should use the graceful period
// instead of the entitled.
Name: "UserLimitExceeded",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: ptr.Ref(int64(300)), Actual: ptr.Ref(int64(200))},
Expected: -1,
},
{
Name: "UserLimitExceededNoEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled, Limit: ptr.Ref(int64(300)), Actual: ptr.Ref(int64(200))},
Expected: 3,
},
{
Name: "HigherLimit",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(110)), Actual: ptr.Ref(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(200))},
Expected: 10, // Diff in the limit #
},
{
Name: "HigherActual",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(300))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(200))},
Expected: 100, // Diff in the actual #
},
{
Name: "LimitExists",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: ptr.Ref(int64(200))},
Expected: 1,
},
{
Name: "LimitExistsGrace",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: nil, Actual: ptr.Ref(int64(200))},
Expected: 1,
},
{
Name: "ActualExists",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: nil},
Expected: 1,
},
{
Name: "NotNils",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil},
Expected: 1,
},
{
Name: "EnabledVsDisabled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Enabled: true, Limit: ptr.Ref(int64(300)), Actual: ptr.Ref(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(300)), Actual: ptr.Ref(int64(200))},
Expected: 1,
},
{
Name: "NotNils",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), Actual: ptr.Ref(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil},
Expected: 1,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
r := tc.A.Compare(tc.B)
logIt := !assert.Equal(t, tc.Expected, r)
// Comparisons should be like addition. A - B = -1 * (B - A)
r = tc.B.Compare(tc.A)
logIt = logIt || !assert.Equalf(t, tc.Expected*-1, r, "the inverse comparison should also be true")
if logIt {
ad, _ := json.Marshal(tc.A)
bd, _ := json.Marshal(tc.B)
t.Logf("a = %s\nb = %s", ad, bd)
}
})
}
}
// TestPremiumSuperSet tests that the "premium" feature set is a superset of the
// "enterprise" feature set.
func TestPremiumSuperSet(t *testing.T) {
t.Parallel()
enterprise := codersdk.FeatureSetEnterprise
premium := codersdk.FeatureSetPremium
// Premium > Enterprise
require.Greater(t, len(premium.Features()), len(enterprise.Features()), "premium should have more features than enterprise")
// Premium ⊃ Enterprise
require.Subset(t, premium.Features(), enterprise.Features(), "premium should be a superset of enterprise. If this fails, update the premium feature set to include all enterprise features.")
// Premium = All Features
// This is currently true. If this assertion changes, update this test
// to reflect the change in feature sets.
require.ElementsMatch(t, premium.Features(), codersdk.FeatureNames, "premium should contain all features")
// This check exists because if you misuse the slices.Delete, you can end up
// with zero'd values.
require.NotContains(t, enterprise.Features(), "", "enterprise should not contain empty string")
require.NotContains(t, premium.Features(), "", "premium should not contain empty string")
}

View File

@ -570,7 +570,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
entitlements, err := license.Entitlements( entitlements, err := license.Entitlements(
ctx, api.Database, ctx, api.Database,
api.Logger, len(agedReplicas), len(api.ExternalAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{ len(agedReplicas), len(api.ExternalAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{
codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureAuditLog: api.AuditLogging,
codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureBrowserOnly: api.BrowserOnly,
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
@ -583,7 +583,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureUserRoleManagement: true, codersdk.FeatureUserRoleManagement: true,
codersdk.FeatureAccessControl: true, codersdk.FeatureAccessControl: true,
codersdk.FeatureControlSharedPorts: true, codersdk.FeatureControlSharedPorts: true,
codersdk.FeatureMultipleOrganizations: true,
}) })
if err != nil { if err != nil {
return err return err

View File

@ -146,15 +146,55 @@ func NewWithAPI(t *testing.T, options *Options) (
return client, provisionerCloser, coderAPI, user return client, provisionerCloser, coderAPI, user
} }
// LicenseOptions is used to generate a license for testing.
// It supports the builder pattern for easy customization.
type LicenseOptions struct { type LicenseOptions struct {
AccountType string AccountType string
AccountID string AccountID string
DeploymentIDs []string DeploymentIDs []string
Trial bool Trial bool
FeatureSet codersdk.FeatureSet
AllFeatures bool AllFeatures bool
GraceAt time.Time // GraceAt is the time at which the license will enter the grace period.
ExpiresAt time.Time GraceAt time.Time
Features license.Features // ExpiresAt is the time at which the license will hard expire.
// ExpiresAt should always be greater then GraceAt.
ExpiresAt time.Time
Features license.Features
}
func (opts *LicenseOptions) Expired(now time.Time) *LicenseOptions {
opts.ExpiresAt = now.Add(time.Hour * 24 * -2)
opts.GraceAt = now.Add(time.Hour * 24 * -3)
return opts
}
func (opts *LicenseOptions) GracePeriod(now time.Time) *LicenseOptions {
opts.ExpiresAt = now.Add(time.Hour * 24)
opts.GraceAt = now.Add(time.Hour * 24 * -1)
return opts
}
func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
opts.GraceAt = now.Add(time.Hour * 24 * 53)
return opts
}
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureUserLimit, limit)
}
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
}
opts.Features[name] = value
return opts
}
func (opts *LicenseOptions) Generate(t *testing.T) string {
return GenerateLicense(t, *opts)
} }
// AddFullLicense generates a license with all features enabled. // AddFullLicense generates a license with all features enabled.
@ -195,6 +235,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
Trial: options.Trial, Trial: options.Trial,
Version: license.CurrentVersion, Version: license.CurrentVersion,
AllFeatures: options.AllFeatures, AllFeatures: options.AllFeatures,
FeatureSet: options.FeatureSet,
Features: options.Features, Features: options.Features,
} }
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

View File

@ -0,0 +1,32 @@
// Package license provides the license parsing and validation logic for Coderd.
// Licensing in Coderd defines what features are allowed to be used in a
// given deployment. Without a license, or with a license that grants 0 features,
// Coderd will refuse to execute some feature code paths. These features are
// typically gated with a middleware that checks the license before allowing
// the http request to proceed.
//
// Terms:
// - FeatureName: A specific functionality that Coderd provides, such as
// external provisioners.
//
// - Feature: Entitlement definition for a FeatureName. A feature can be:
// - "entitled": The feature is allowed to be used by the deployment.
// - "grace period": The feature is allowed to be used by the deployment,
// but the license is expired. There is a grace period
// before the feature is disabled.
// - "not entitled": The deployment is not allowed to use the feature.
// Either by expiration, or by not being included
// in the license.
// A feature can also be "disabled" that prevents usage of the feature
// even if entitled. This is usually a deployment configuration option.
//
// - License: A signed JWT that lists the features that are allowed to be used by
// a given deployment. A license can have extra properties like,
// `IsTrial`, `DeploymentIDs`, etc that can be used to further define
// usage of the license.
/**/
// - Entitlements: A parsed set of licenses. Yes you can have more than 1 license
// on a deployment! Entitlements will enumerate all features that
// are allowed to be used.
//
package license

View File

@ -10,8 +10,6 @@ import (
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@ -21,58 +19,103 @@ import (
func Entitlements( func Entitlements(
ctx context.Context, ctx context.Context,
db database.Store, db database.Store,
logger slog.Logger,
replicaCount int, replicaCount int,
externalAuthCount int, externalAuthCount int,
keys map[string]ed25519.PublicKey, keys map[string]ed25519.PublicKey,
enablements map[codersdk.FeatureName]bool, enablements map[codersdk.FeatureName]bool,
) (codersdk.Entitlements, error) { ) (codersdk.Entitlements, error) {
now := time.Now() now := time.Now()
// Default all entitlements to be disabled.
entitlements := codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{},
Warnings: []string{},
Errors: []string{},
}
for _, featureName := range codersdk.FeatureNames {
entitlements.Features[featureName] = codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: enablements[featureName],
}
}
// nolint:gocritic // Getting unexpired licenses is a system function. // nolint:gocritic // Getting unexpired licenses is a system function.
licenses, err := db.GetUnexpiredLicenses(dbauthz.AsSystemRestricted(ctx)) licenses, err := db.GetUnexpiredLicenses(dbauthz.AsSystemRestricted(ctx))
if err != nil { if err != nil {
return entitlements, err return codersdk.Entitlements{}, err
} }
// nolint:gocritic // Getting active user count is a system function. // nolint:gocritic // Getting active user count is a system function.
activeUserCount, err := db.GetActiveUserCount(dbauthz.AsSystemRestricted(ctx)) activeUserCount, err := db.GetActiveUserCount(dbauthz.AsSystemRestricted(ctx))
if err != nil { if err != nil {
return entitlements, xerrors.Errorf("query active user count: %w", err) return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err)
} }
// always shows active user count regardless of license // always shows active user count regardless of license
entitlements.Features[codersdk.FeatureUserLimit] = codersdk.Feature{ entitlements, err := LicensesEntitlements(now, licenses, enablements, keys, FeatureArguments{
Entitlement: codersdk.EntitlementNotEntitled, ActiveUserCount: activeUserCount,
Enabled: enablements[codersdk.FeatureUserLimit], ReplicaCount: replicaCount,
Actual: &activeUserCount, ExternalAuthCount: externalAuthCount,
})
if err != nil {
return entitlements, err
} }
allFeatures := false return entitlements, nil
allFeaturesEntitlement := codersdk.EntitlementNotEntitled }
// Here we loop through licenses to detect enabled features. type FeatureArguments struct {
for _, l := range licenses { ActiveUserCount int64
claims, err := ParseClaims(l.JWT, keys) ReplicaCount int
ExternalAuthCount int
}
// LicensesEntitlements returns the entitlements for licenses. Entitlements are
// merged from all licenses and the highest entitlement is used for each feature.
// Arguments:
//
// now: The time to use for checking license expiration.
// license: The license to check.
// enablements: Features can be explicitly disabled by the deployment even if
// the license has the feature entitled. Features can also have
// the 'feat.AlwaysEnable()' return true to disallow disabling.
// featureArguments: Additional arguments required by specific features.
func LicensesEntitlements(
now time.Time,
licenses []database.License,
enablements map[codersdk.FeatureName]bool,
keys map[string]ed25519.PublicKey,
featureArguments FeatureArguments,
) (codersdk.Entitlements, error) {
// Default all entitlements to be disabled.
entitlements := codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{
// always shows active user count regardless of license.
codersdk.FeatureUserLimit: {
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: enablements[codersdk.FeatureUserLimit],
Actual: &featureArguments.ActiveUserCount,
},
},
Warnings: []string{},
Errors: []string{},
}
// By default, enumerate all features and set them to not entitled.
for _, featureName := range codersdk.FeatureNames {
entitlements.AddFeature(featureName, codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: enablements[featureName],
})
}
// TODO: License specific warnings and errors should be tied to the license, not the
// 'Entitlements' group as a whole.
for _, license := range licenses {
claims, err := ParseClaims(license.JWT, keys)
if err != nil { if err != nil {
logger.Debug(ctx, "skipping invalid license", entitlements.Errors = append(entitlements.Errors,
slog.F("id", l.ID), slog.Error(err)) fmt.Sprintf("Invalid license (%s) parsing claims: %s", license.UUID.String(), err.Error()))
continue continue
} }
// Any valid license should toggle this boolean
entitlements.HasLicense = true entitlements.HasLicense = true
// If any license requires telemetry, the deployment should require telemetry.
entitlements.RequireTelemetry = entitlements.RequireTelemetry || claims.RequireTelemetry
// entitlement is the highest entitlement for any features in this license.
entitlement := codersdk.EntitlementEntitled entitlement := codersdk.EntitlementEntitled
// If any license is a trial license, this should be set to true.
// The user should delete the trial license to remove this.
entitlements.Trial = claims.Trial entitlements.Trial = claims.Trial
if now.After(claims.LicenseExpires.Time) { if now.After(claims.LicenseExpires.Time) {
// if the grace period were over, the validation fails, so if we are after // if the grace period were over, the validation fails, so if we are after
@ -80,22 +123,32 @@ func Entitlements(
entitlement = codersdk.EntitlementGracePeriod entitlement = codersdk.EntitlementGracePeriod
} }
// Add warning if license is expiring soon // Will add a warning if the license is expiring soon.
daysToExpire := int(math.Ceil(claims.LicenseExpires.Sub(now).Hours() / 24)) // This warning can be raised multiple times if there is more than 1 license.
isTrial := entitlements.Trial licenseExpirationWarning(&entitlements, now, claims)
showWarningDays := 30
if isTrial { // 'claims.AllFeature' is the legacy way to set 'claims.FeatureSet = codersdk.FeatureSetEnterprise'
showWarningDays = 7 // If both are set, ignore the legacy 'claims.AllFeature'
} if claims.AllFeatures && claims.FeatureSet == "" {
isExpiringSoon := daysToExpire > 0 && daysToExpire < showWarningDays claims.FeatureSet = codersdk.FeatureSetEnterprise
if isExpiringSoon {
day := "day"
if daysToExpire > 1 {
day = "days"
}
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("Your license expires in %d %s.", daysToExpire, day))
} }
// Add all features from the feature set defined.
for _, featureName := range claims.FeatureSet.Features() {
if featureName == codersdk.FeatureUserLimit {
// FeatureUserLimit is unique in that it must be specifically defined
// in the license. There is no default meaning if no "limit" is set.
continue
}
entitlements.AddFeature(featureName, codersdk.Feature{
Entitlement: entitlement,
Enabled: enablements[featureName] || featureName.AlwaysEnable(),
Limit: nil,
Actual: nil,
})
}
// Features al-la-carte
for featureName, featureValue := range claims.Features { for featureName, featureValue := range claims.Features {
// Can this be negative? // Can this be negative?
if featureValue <= 0 { if featureValue <= 0 {
@ -103,86 +156,28 @@ func Entitlements(
} }
switch featureName { switch featureName {
// User limit has special treatment as our only non-boolean feature.
case codersdk.FeatureUserLimit: case codersdk.FeatureUserLimit:
// User limit has special treatment as our only non-boolean feature.
limit := featureValue limit := featureValue
priorLimit := entitlements.Features[codersdk.FeatureUserLimit] entitlements.AddFeature(codersdk.FeatureUserLimit, codersdk.Feature{
if priorLimit.Limit != nil && *priorLimit.Limit > limit {
limit = *priorLimit.Limit
}
entitlements.Features[codersdk.FeatureUserLimit] = codersdk.Feature{
Enabled: true, Enabled: true,
Entitlement: entitlement, Entitlement: entitlement,
Limit: &limit, Limit: &limit,
Actual: &activeUserCount, Actual: &featureArguments.ActiveUserCount,
} })
default: default:
entitlements.Features[featureName] = codersdk.Feature{ entitlements.Features[featureName] = codersdk.Feature{
Entitlement: maxEntitlement(entitlements.Features[featureName].Entitlement, entitlement), Entitlement: entitlement,
Enabled: enablements[featureName] || featureName.AlwaysEnable(), Enabled: enablements[featureName] || featureName.AlwaysEnable(),
} }
} }
} }
if claims.AllFeatures {
allFeatures = true
allFeaturesEntitlement = maxEntitlement(allFeaturesEntitlement, entitlement)
}
entitlements.RequireTelemetry = entitlements.RequireTelemetry || claims.RequireTelemetry
} }
if allFeatures { // Now the license specific warnings and errors are added to the entitlements.
for _, featureName := range codersdk.FeatureNames {
// No user limit!
if featureName == codersdk.FeatureUserLimit {
continue
}
feature := entitlements.Features[featureName]
feature.Entitlement = maxEntitlement(feature.Entitlement, allFeaturesEntitlement)
feature.Enabled = enablements[featureName] || featureName.AlwaysEnable()
entitlements.Features[featureName] = feature
}
}
if entitlements.HasLicense { // If HA is enabled, ensure the feature is entitled.
userLimit := entitlements.Features[codersdk.FeatureUserLimit].Limit if featureArguments.ReplicaCount > 1 {
if userLimit != nil && activeUserCount > *userLimit {
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
"Your deployment has %d active users but is only licensed for %d.",
activeUserCount, *userLimit))
}
for _, featureName := range codersdk.FeatureNames {
// The user limit has it's own warnings!
if featureName == codersdk.FeatureUserLimit {
continue
}
// High availability has it's own warnings based on replica count!
if featureName == codersdk.FeatureHighAvailability {
continue
}
// External Auth Providers auth has it's own warnings based on the number configured!
if featureName == codersdk.FeatureMultipleExternalAuth {
continue
}
feature := entitlements.Features[featureName]
if !feature.Enabled {
continue
}
niceName := featureName.Humanize()
switch feature.Entitlement {
case codersdk.EntitlementNotEntitled:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName))
case codersdk.EntitlementGracePeriod:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName))
default:
}
}
}
if replicaCount > 1 {
feature := entitlements.Features[codersdk.FeatureHighAvailability] feature := entitlements.Features[codersdk.FeatureHighAvailability]
switch feature.Entitlement { switch feature.Entitlement {
@ -200,7 +195,7 @@ func Entitlements(
} }
} }
if externalAuthCount > 1 { if featureArguments.ExternalAuthCount > 1 {
feature := entitlements.Features[codersdk.FeatureMultipleExternalAuth] feature := entitlements.Features[codersdk.FeatureMultipleExternalAuth]
switch feature.Entitlement { switch feature.Entitlement {
@ -221,6 +216,52 @@ func Entitlements(
} }
} }
if entitlements.HasLicense {
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
if userLimit.Limit != nil && featureArguments.ActiveUserCount > *userLimit.Limit {
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
"Your deployment has %d active users but is only licensed for %d.",
featureArguments.ActiveUserCount, *userLimit.Limit))
} else if userLimit.Limit != nil && userLimit.Entitlement == codersdk.EntitlementGracePeriod {
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
"Your deployment has %d active users but the license with the limit %d is expired.",
featureArguments.ActiveUserCount, *userLimit.Limit))
}
// Add a warning for every feature that is enabled but not entitled or
// is in a grace period.
for _, featureName := range codersdk.FeatureNames {
// The user limit has it's own warnings!
if featureName == codersdk.FeatureUserLimit {
continue
}
// High availability has it's own warnings based on replica count!
if featureName == codersdk.FeatureHighAvailability {
continue
}
// External Auth Providers auth has it's own warnings based on the number configured!
if featureName == codersdk.FeatureMultipleExternalAuth {
continue
}
feature := entitlements.Features[featureName]
if !feature.Enabled {
continue
}
niceName := featureName.Humanize()
switch feature.Entitlement {
case codersdk.EntitlementNotEntitled:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName))
case codersdk.EntitlementGracePeriod:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName))
default:
}
}
}
// Wrap up by disabling all features that are not entitled.
for _, featureName := range codersdk.FeatureNames { for _, featureName := range codersdk.FeatureNames {
feature := entitlements.Features[featureName] feature := entitlements.Features[featureName]
if feature.Entitlement == codersdk.EntitlementNotEntitled { if feature.Entitlement == codersdk.EntitlementNotEntitled {
@ -261,9 +302,12 @@ type Claims struct {
AccountType string `json:"account_type,omitempty"` AccountType string `json:"account_type,omitempty"`
AccountID string `json:"account_id,omitempty"` AccountID string `json:"account_id,omitempty"`
// DeploymentIDs enforces the license can only be used on a set of deployments. // DeploymentIDs enforces the license can only be used on a set of deployments.
DeploymentIDs []string `json:"deployment_ids,omitempty"` DeploymentIDs []string `json:"deployment_ids,omitempty"`
Trial bool `json:"trial"` Trial bool `json:"trial"`
AllFeatures bool `json:"all_features"` FeatureSet codersdk.FeatureSet `json:"feature_set"`
// AllFeatures represents 'FeatureSet = FeatureSetEnterprise'
// Deprecated: AllFeatures is deprecated in favor of FeatureSet.
AllFeatures bool `json:"all_features,omitempty"`
Version uint64 `json:"version"` Version uint64 `json:"version"`
Features Features `json:"features"` Features Features `json:"features"`
RequireTelemetry bool `json:"require_telemetry,omitempty"` RequireTelemetry bool `json:"require_telemetry,omitempty"`
@ -330,13 +374,21 @@ func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, e
} }
} }
// maxEntitlement is the "greater" entitlement between the given values // licenseExpirationWarning adds a warning message if the license is expiring soon.
func maxEntitlement(e1, e2 codersdk.Entitlement) codersdk.Entitlement { func licenseExpirationWarning(entitlements *codersdk.Entitlements, now time.Time, claims *Claims) {
if e1 == codersdk.EntitlementEntitled || e2 == codersdk.EntitlementEntitled { // Add warning if license is expiring soon
return codersdk.EntitlementEntitled daysToExpire := int(math.Ceil(claims.LicenseExpires.Sub(now).Hours() / 24))
showWarningDays := 30
isTrial := entitlements.Trial
if isTrial {
showWarningDays = 7
} }
if e1 == codersdk.EntitlementGracePeriod || e2 == codersdk.EntitlementGracePeriod { isExpiringSoon := daysToExpire > 0 && daysToExpire < showWarningDays
return codersdk.EntitlementGracePeriod if isExpiringSoon {
day := "day"
if daysToExpire > 1 {
day = "days"
}
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("Your license expires in %d %s.", daysToExpire, day))
} }
return codersdk.EntitlementNotEntitled
} }

View File

@ -7,9 +7,10 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/dbtime"
@ -30,7 +31,7 @@ func TestEntitlements(t *testing.T) {
t.Run("Defaults", func(t *testing.T) { t.Run("Defaults", func(t *testing.T) {
t.Parallel() t.Parallel()
db := dbmem.New() db := dbmem.New()
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.False(t, entitlements.HasLicense) require.False(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -42,7 +43,7 @@ func TestEntitlements(t *testing.T) {
t.Run("Always return the current user count", func(t *testing.T) { t.Run("Always return the current user count", func(t *testing.T) {
t.Parallel() t.Parallel()
db := dbmem.New() db := dbmem.New()
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.False(t, entitlements.HasLicense) require.False(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -55,7 +56,7 @@ func TestEntitlements(t *testing.T) {
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -79,7 +80,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -102,7 +103,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -129,7 +130,7 @@ func TestEntitlements(t *testing.T) {
Exp: time.Now().AddDate(0, 0, 5), Exp: time.Now().AddDate(0, 0, 5),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
@ -158,7 +159,7 @@ func TestEntitlements(t *testing.T) {
Exp: time.Now().AddDate(0, 0, 5), Exp: time.Now().AddDate(0, 0, 5),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
@ -188,7 +189,7 @@ func TestEntitlements(t *testing.T) {
Exp: time.Now().AddDate(0, 0, 5), Exp: time.Now().AddDate(0, 0, 5),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
@ -217,7 +218,7 @@ func TestEntitlements(t *testing.T) {
Exp: time.Now().AddDate(0, 0, 5), Exp: time.Now().AddDate(0, 0, 5),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
@ -237,7 +238,7 @@ func TestEntitlements(t *testing.T) {
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
@ -299,7 +300,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.")
@ -327,7 +328,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(60 * 24 * time.Hour), Exp: time.Now().Add(60 * 24 * time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.Empty(t, entitlements.Warnings) require.Empty(t, entitlements.Warnings)
@ -350,12 +351,96 @@ func TestEntitlements(t *testing.T) {
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
}) })
t.Run("Enterprise", func(t *testing.T) {
t.Parallel()
db := dbmem.New()
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
Exp: time.Now().Add(time.Hour),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetEnterprise,
}),
})
require.NoError(t, err)
entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial)
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit {
continue
}
if slices.Contains(enterpriseFeatures, featureName) {
require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
} else {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
}
})
t.Run("Premium", func(t *testing.T) {
t.Parallel()
db := dbmem.New()
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
Exp: time.Now().Add(time.Hour),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
}),
})
require.NoError(t, err)
entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial)
// All premium features should be entitled
enterpriseFeatures := codersdk.FeatureSetPremium.Features()
for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit {
continue
}
if slices.Contains(enterpriseFeatures, featureName) {
require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
} else {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
}
})
t.Run("SetNone", func(t *testing.T) {
t.Parallel()
db := dbmem.New()
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
Exp: time.Now().Add(time.Hour),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
FeatureSet: "",
}),
})
require.NoError(t, err)
entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial)
for _, featureName := range codersdk.FeatureNames {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
})
// AllFeatures uses the deprecated 'AllFeatures' boolean.
t.Run("AllFeatures", func(t *testing.T) { t.Run("AllFeatures", func(t *testing.T) {
t.Parallel() t.Parallel()
db := dbmem.New() db := dbmem.New()
@ -365,16 +450,24 @@ func TestEntitlements(t *testing.T) {
AllFeatures: true, AllFeatures: true,
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames { for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit { if featureName == codersdk.FeatureUserLimit {
continue continue
} }
require.True(t, entitlements.Features[featureName].Enabled) if slices.Contains(enterpriseFeatures, featureName) {
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement)
} else {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
} }
}) })
@ -387,17 +480,25 @@ func TestEntitlements(t *testing.T) {
AllFeatures: true, AllFeatures: true,
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, empty) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames { for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit { if featureName == codersdk.FeatureUserLimit {
continue continue
} }
feature := entitlements.Features[featureName] feature := entitlements.Features[featureName]
require.Equal(t, featureName.AlwaysEnable(), feature.Enabled) if slices.Contains(enterpriseFeatures, featureName) {
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement) require.Equal(t, featureName.AlwaysEnable(), feature.Enabled)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
} else {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
} }
}) })
@ -412,23 +513,30 @@ func TestEntitlements(t *testing.T) {
ExpiresAt: dbtime.Now().Add(time.Hour), ExpiresAt: dbtime.Now().Add(time.Hour),
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.True(t, entitlements.HasLicense) require.True(t, entitlements.HasLicense)
require.False(t, entitlements.Trial) require.False(t, entitlements.Trial)
// All enterprise features should be entitled
enterpriseFeatures := codersdk.FeatureSetEnterprise.Features()
for _, featureName := range codersdk.FeatureNames { for _, featureName := range codersdk.FeatureNames {
if featureName == codersdk.FeatureUserLimit { if featureName == codersdk.FeatureUserLimit {
continue continue
} }
require.True(t, entitlements.Features[featureName].Enabled) if slices.Contains(enterpriseFeatures, featureName) {
require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement) require.True(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement)
} else {
require.False(t, entitlements.Features[featureName].Enabled, featureName)
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
}
} }
}) })
t.Run("MultipleReplicasNoLicense", func(t *testing.T) { t.Run("MultipleReplicasNoLicense", func(t *testing.T) {
t.Parallel() t.Parallel()
db := dbmem.New() db := dbmem.New()
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.False(t, entitlements.HasLicense) require.False(t, entitlements.HasLicense)
require.Len(t, entitlements.Errors, 1) require.Len(t, entitlements.Errors, 1)
@ -446,7 +554,7 @@ func TestEntitlements(t *testing.T) {
}, },
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{
codersdk.FeatureHighAvailability: true, codersdk.FeatureHighAvailability: true,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -468,7 +576,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{ entitlements, err := license.Entitlements(context.Background(), db, 2, 1, coderdenttest.Keys, map[codersdk.FeatureName]bool{
codersdk.FeatureHighAvailability: true, codersdk.FeatureHighAvailability: true,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -480,7 +588,7 @@ func TestEntitlements(t *testing.T) {
t.Run("MultipleGitAuthNoLicense", func(t *testing.T) { t.Run("MultipleGitAuthNoLicense", func(t *testing.T) {
t.Parallel() t.Parallel()
db := dbmem.New() db := dbmem.New()
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, all) entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, all)
require.NoError(t, err) require.NoError(t, err)
require.False(t, entitlements.HasLicense) require.False(t, entitlements.HasLicense)
require.Len(t, entitlements.Errors, 1) require.Len(t, entitlements.Errors, 1)
@ -498,7 +606,7 @@ func TestEntitlements(t *testing.T) {
}, },
}), }),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{
codersdk.FeatureMultipleExternalAuth: true, codersdk.FeatureMultipleExternalAuth: true,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -520,7 +628,7 @@ func TestEntitlements(t *testing.T) {
}), }),
Exp: time.Now().Add(time.Hour), Exp: time.Now().Add(time.Hour),
}) })
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{ entitlements, err := license.Entitlements(context.Background(), db, 1, 2, coderdenttest.Keys, map[codersdk.FeatureName]bool{
codersdk.FeatureMultipleExternalAuth: true, codersdk.FeatureMultipleExternalAuth: true,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -529,3 +637,236 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, "You have multiple External Auth Providers configured but your license is expired. Reduce to one.", entitlements.Warnings[0]) require.Equal(t, "You have multiple External Auth Providers configured but your license is expired. Reduce to one.", entitlements.Warnings[0])
}) })
} }
func TestLicenseEntitlements(t *testing.T) {
t.Parallel()
// We must use actual 'time.Now()' in tests because the jwt library does
// not accept a custom time function. The only way to change it is as a
// package global, which does not work in t.Parallel().
// This list comes from coderd.go on launch. This list is a bit arbitrary,
// maybe some should be moved to "AlwaysEnabled" instead.
defaultEnablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAuditLog: true,
codersdk.FeatureBrowserOnly: true,
codersdk.FeatureSCIM: true,
codersdk.FeatureMultipleExternalAuth: true,
codersdk.FeatureTemplateRBAC: true,
codersdk.FeatureExternalTokenEncryption: true,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureAdvancedTemplateScheduling: true,
codersdk.FeatureWorkspaceProxy: true,
codersdk.FeatureUserRoleManagement: true,
codersdk.FeatureAccessControl: true,
codersdk.FeatureControlSharedPorts: true,
}
legacyLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "Alice",
Trial: false,
// Use the legacy boolean
AllFeatures: true,
}).Valid(time.Now())
}
enterpriseLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "Bob",
DeploymentIDs: nil,
Trial: false,
FeatureSet: codersdk.FeatureSetEnterprise,
AllFeatures: true,
}).Valid(time.Now())
}
premiumLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "Charlie",
DeploymentIDs: nil,
Trial: false,
FeatureSet: codersdk.FeatureSetPremium,
AllFeatures: true,
}).Valid(time.Now())
}
testCases := []struct {
Name string
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
}{
{
Name: "NoLicenses",
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
assert.False(t, entitlements.HasLicense)
assert.False(t, entitlements.Trial)
},
},
{
Name: "MixedUsedCounts",
Licenses: []*coderdenttest.LicenseOptions{
legacyLicense().UserLimit(100),
enterpriseLicense().UserLimit(500),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{
ActiveUserCount: 50,
ReplicaCount: 0,
ExternalAuthCount: 0,
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertEnterpriseFeatures(t, entitlements)
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
assert.Equalf(t, int64(500), *userFeature.Limit, "user limit")
assert.Equalf(t, int64(50), *userFeature.Actual, "user count")
},
},
{
Name: "MixedUsedCountsWithExpired",
Licenses: []*coderdenttest.LicenseOptions{
// This license is ignored
enterpriseLicense().UserLimit(500).Expired(time.Now()),
enterpriseLicense().UserLimit(100),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{
ActiveUserCount: 200,
ReplicaCount: 0,
ExternalAuthCount: 0,
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertEnterpriseFeatures(t, entitlements)
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
assert.Equalf(t, int64(100), *userFeature.Limit, "user limit")
assert.Equalf(t, int64(200), *userFeature.Actual, "user count")
require.Len(t, entitlements.Errors, 1, "invalid license error")
require.Len(t, entitlements.Warnings, 1, "user count exceeds warning")
require.Contains(t, entitlements.Errors[0], "Invalid license")
require.Contains(t, entitlements.Warnings[0], "active users but is only licensed for")
},
},
{
// The new license does not have enough seats to cover the active user count.
// The old license is in it's grace period.
Name: "MixedUsedCountsWithGrace",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(500).GracePeriod(time.Now()),
enterpriseLicense().UserLimit(100),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{
ActiveUserCount: 200,
ReplicaCount: 0,
ExternalAuthCount: 0,
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
assert.Equalf(t, int64(500), *userFeature.Limit, "user limit")
assert.Equalf(t, int64(200), *userFeature.Actual, "user count")
assert.Equal(t, userFeature.Entitlement, codersdk.EntitlementGracePeriod)
},
},
{
// Legacy license uses the "AllFeatures" boolean
Name: "LegacyLicense",
Licenses: []*coderdenttest.LicenseOptions{
legacyLicense().UserLimit(100),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{
ActiveUserCount: 50,
ReplicaCount: 0,
ExternalAuthCount: 0,
},
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assertEnterpriseFeatures(t, entitlements)
assertNoErrors(t, entitlements)
assertNoWarnings(t, entitlements)
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
assert.Equalf(t, int64(100), *userFeature.Limit, "user limit")
assert.Equalf(t, int64(50), *userFeature.Actual, "user count")
},
},
{
Name: "EnterpriseDisabledMultiOrg",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{},
ExpectedErrorContains: "",
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.False(t, entitlements.Features[codersdk.FeatureMultipleOrganizations].Enabled, "multi-org only enabled for premium")
},
},
{
Name: "PremiumEnabledMultiOrg",
Licenses: []*coderdenttest.LicenseOptions{
premiumLicense().UserLimit(100),
},
Enablements: defaultEnablements,
Arguments: license.FeatureArguments{},
ExpectedErrorContains: "",
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
assert.True(t, entitlements.Features[codersdk.FeatureMultipleOrganizations].Enabled, "multi-org enabled for premium")
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
generatedLicenses := make([]database.License, 0, len(tc.Licenses))
for i, lo := range tc.Licenses {
generatedLicenses = append(generatedLicenses, database.License{
ID: int32(i),
UploadedAt: time.Now().Add(time.Hour * -1),
JWT: lo.Generate(t),
Exp: lo.GraceAt,
UUID: uuid.New(),
})
}
entitlements, err := license.LicensesEntitlements(time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
} else {
require.NoError(t, err)
tc.AssertEntitlements(t, entitlements)
}
})
}
}
func assertNoErrors(t *testing.T, entitlements codersdk.Entitlements) {
assert.Empty(t, entitlements.Errors, "no errors")
}
func assertNoWarnings(t *testing.T, entitlements codersdk.Entitlements) {
assert.Empty(t, entitlements.Warnings, "no warnings")
}
func assertEnterpriseFeatures(t *testing.T, entitlements codersdk.Entitlements) {
for _, expected := range codersdk.FeatureSetEnterprise.Features() {
f := entitlements.Features[expected]
assert.Equalf(t, codersdk.EntitlementEntitled, f.Entitlement, "%s entitled", expected)
assert.Equalf(t, true, f.Enabled, "%s enabled", expected)
}
}

View File

@ -2104,6 +2104,10 @@ export const FeatureNames: FeatureName[] = [
"workspace_proxy", "workspace_proxy",
]; ];
// From codersdk/deployment.go
export type FeatureSet = "" | "enterprise" | "premium";
export const FeatureSets: FeatureSet[] = ["", "enterprise", "premium"];
// From codersdk/groups.go // From codersdk/groups.go
export type GroupSource = "oidc" | "user"; export type GroupSource = "oidc" | "user";
export const GroupSources: GroupSource[] = ["oidc", "user"]; export const GroupSources: GroupSource[] = ["oidc", "user"];