diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 54e29e9882..7c2850056e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -57,6 +57,111 @@ func (e Entitlement) Weight() int { } } +// Addon represents a grouping of features used for additional license SKUs. +// It is complementary to FeatureSet and similar in implementation, allowing +// features to be grouped together dynamically. Unlike FeatureSet, licenses +// can have multiple addons. This also means that entitlements don't require +// reissuing when new features are added to an addon. +type Addon string + +const ( + AddonAIGovernance Addon = "ai_governance" +) + +var ( + // AddonsNames must be kept in-sync with the Addon enum above. + AddonsNames = []Addon{ + AddonAIGovernance, + } + + // AddonsMap is a map of all addon names for quick lookups. + AddonsMap = func() map[Addon]struct{} { + addonsMap := make(map[Addon]struct{}, len(AddonsNames)) + for _, addon := range AddonsNames { + addonsMap[addon] = struct{}{} + } + return addonsMap + }() +) + +// Features returns all the features that are part of the addon. +func (a Addon) Features() []FeatureName { + switch a { + case AddonAIGovernance: + // Return all AI governance features. + var features []FeatureName + for _, featureName := range FeatureNames { + if featureName.IsAIGovernanceAddon() { + features = append(features, featureName) + } + } + return features + default: + return nil + } +} + +// ValidateDependencies validates the dependencies of the addon +// and returns a list of errors for the missing dependencies. +func (a Addon) ValidateDependencies(features map[FeatureName]Feature) []string { + errors := []string{} + + // Candidate for a switch statement once we have more addons. + if a == AddonAIGovernance { + requiredFeatures := []FeatureName{ + FeatureAIGovernanceUserLimit, + } + + for _, featureName := range requiredFeatures { + feature, ok := features[featureName] + if !ok { + errors = append(errors, + fmt.Sprintf( + "Feature %s must be set when using the %s addon.", + featureName.Humanize(), + a.Humanize(), + ), + ) + continue + } + // For limit features, check if the Limit is set (not nil). + // For usage period features, check if the Limit is set. + if featureName.UsesLimit() || featureName.UsesUsagePeriod() { + if feature.Limit == nil { + errors = append(errors, + fmt.Sprintf( + "Feature %s must be set when using the %s addon.", + featureName.Humanize(), + a.Humanize(), + ), + ) + } + } else if feature.Entitlement == EntitlementNotEntitled { + // For non-limit features, check if the feature is entitled. + errors = append(errors, + fmt.Sprintf( + "Feature %s must be set when using the %s addon.", + featureName.Humanize(), + a.Humanize(), + ), + ) + } + } + } + + return errors +} + +// Humanize returns the addon name in a human-readable format. +func (a Addon) Humanize() string { + switch a { + case AddonAIGovernance: + return "AI Governance" + default: + return strings.Title(strings.ReplaceAll(string(a), "_", " ")) + } +} + // 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 // array below. @@ -91,6 +196,7 @@ const ( FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent" FeatureAIBridge FeatureName = "aibridge" FeatureBoundary FeatureName = "boundary" + FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit" ) var ( @@ -121,6 +227,7 @@ var ( FeatureWorkspaceExternalAgent, FeatureAIBridge, FeatureBoundary, + FeatureAIGovernanceUserLimit, } // FeatureNamesMap is a map of all feature names for quick lookups. @@ -142,6 +249,8 @@ func (n FeatureName) Humanize() string { return "SCIM" case FeatureAIBridge: return "AI Bridge" + case FeatureAIGovernanceUserLimit: + return "AI Governance User Limit" default: return strings.Title(strings.ReplaceAll(string(n), "_", " ")) } @@ -184,8 +293,9 @@ func (n FeatureName) Enterprise() bool { // be included in any feature sets (as they are not boolean features). func (n FeatureName) UsesLimit() bool { return map[FeatureName]bool{ - FeatureUserLimit: true, - FeatureManagedAgentLimit: true, + FeatureUserLimit: true, + FeatureManagedAgentLimit: true, + FeatureAIGovernanceUserLimit: true, }[n] } @@ -196,6 +306,20 @@ func (n FeatureName) UsesUsagePeriod() bool { }[n] } +// IsAIGovernanceAddon returns true if the feature is an AI governance addon feature. +func (n FeatureName) IsAIGovernanceAddon() bool { + return n == FeatureAIBridge || n == FeatureBoundary +} + +// IsAddon returns true if the feature is an addon feature. +func (n FeatureName) IsAddonFeature() bool { + features := []FeatureName{} + for addon := range AddonsMap { + features = append(features, addon.Features()...) + } + return slices.Contains(features, 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 @@ -220,6 +344,7 @@ func (set FeatureSet) Features() []FeatureName { copy(enterpriseFeatures, FeatureNames) // Remove the selection enterpriseFeatures = slices.DeleteFunc(enterpriseFeatures, func(f FeatureName) bool { + // TODO: In future release, restore the f.IsAddonFeature() check. return !f.Enterprise() || f.UsesLimit() }) @@ -229,6 +354,7 @@ func (set FeatureSet) Features() []FeatureName { copy(premiumFeatures, FeatureNames) // Remove the selection premiumFeatures = slices.DeleteFunc(premiumFeatures, func(f FeatureName) bool { + // TODO: In future release, restore the f.IsAddonFeature() check. return f.UsesLimit() }) // FeatureSetPremium is just all features. diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 3590e5455c..38f0ecbdac 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -623,7 +623,8 @@ func TestPremiumSuperSet(t *testing.T) { // 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 EXCEPT usage limit features + // Premium = All Features EXCEPT limit-based features. + // TODO: In future release, also exclude addon features (f.IsAddonFeature()). expectedPremiumFeatures := []codersdk.FeatureName{} for _, feature := range codersdk.FeatureNames { if feature.UsesLimit() { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 8ef44cc7cb..71a43484d5 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -185,6 +185,7 @@ type LicenseOptions struct { // past. IssuedAt time.Time Features license.Features + Addons []codersdk.Addon AllowEmpty bool } @@ -225,6 +226,11 @@ func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions { return opts.Feature(codersdk.FeatureUserLimit, limit) } +func (opts *LicenseOptions) AIGovernanceAddon(limit int64) *LicenseOptions { + opts.Addons = append(opts.Addons, codersdk.AddonAIGovernance) + return opts.Feature(codersdk.FeatureAIGovernanceUserLimit, limit) +} + func (opts *LicenseOptions) ManagedAgentLimit(soft int64, hard int64) *LicenseOptions { // These don't use named or exported feature names, see // enterprise/coderd/license/license.go. @@ -301,6 +307,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { AllFeatures: options.AllFeatures, FeatureSet: options.FeatureSet, Features: options.Features, + Addons: options.Addons, PublishUsageData: options.PublishUsageData, } return GenerateLicenseRaw(t, c) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ad5c5a861b..08c76f2bd9 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "math" + "slices" "sort" "time" @@ -298,11 +299,19 @@ func LicensesEntitlements( }) } - // Add all features from the feature set defined. + // TODO: Remove this tracking once AI Bridge is enforced as an add-on license. + // Track explicit AI Bridge entitlement (add-on license). This is checked + // at the license level since AI Bridge may come from the FeatureSet + // (Premium) rather than being explicitly listed in claims.Features. + // Only having the AI Governance addon should suppress the soft warning. + if slices.Contains(claims.Addons, codersdk.AddonAIGovernance) { + hasExplicitAIBridgeEntitlement = true + } + + // Add all features from the feature set. for _, featureName := range claims.FeatureSet.Features() { if _, ok := licenseForbiddenFeatures[featureName]; ok { - // Ignore any FeatureSet features that are forbidden to be set - // in a license. + // Ignore any FeatureSet features that are forbidden to be set in a license. continue } if _, ok := featureGrouping[featureName]; ok { @@ -310,8 +319,8 @@ func LicensesEntitlements( // multiple feature values into a single SDK feature. continue } - if featureName == codersdk.FeatureUserLimit || featureName.UsesUsagePeriod() { - // FeatureUserLimit and usage period features are handled below. + if featureName.UsesLimit() || featureName.UsesUsagePeriod() { + // Limit and usage period features are handled below. // They don't provide default values as they are always enabled // and require a limit to be specified in the license to have // any effect. @@ -341,12 +350,6 @@ func LicensesEntitlements( continue } - // TODO: Remove this tracking once AI Bridge is enforced as an add-on license. - // Track explicit AI Bridge entitlement (add-on license). - if featureName == codersdk.FeatureAIBridge && featureValue > 0 { - hasExplicitAIBridgeEntitlement = true - } - // Special handling for grouped (e.g. usage period) features. if grouping, ok := featureGrouping[featureName]; ok { ul := uncommittedUsageFeatures[grouping.sdkFeature] @@ -367,18 +370,25 @@ func LicensesEntitlements( continue } - // Handling for non-grouped features. - switch featureName { - case codersdk.FeatureUserLimit: + // Handling for limit features. + switch { + case featureName.UsesLimit(): if featureValue <= 0 { - // 0 user count doesn't make sense, so we skip it. + // 0 limit value or less doesn't make sense, so we skip it. continue } - entitlements.AddFeature(codersdk.FeatureUserLimit, codersdk.Feature{ + + // When we have a limit feature, we need to set the actual value (if available). + var actual *int64 + if featureName == codersdk.FeatureUserLimit { + actual = &featureArguments.ActiveUserCount + } + + entitlements.AddFeature(featureName, codersdk.Feature{ Enabled: true, Entitlement: entitlement, Limit: &featureValue, - Actual: &featureArguments.ActiveUserCount, + Actual: actual, }) default: if featureValue <= 0 { @@ -431,6 +441,35 @@ func LicensesEntitlements( } entitlements.AddFeature(featureName, feature) } + + addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature) + + // Finally, add all features from the addons. We do this last so that + // any dependencies of an addon are validated against the calculated + // found entitlements. This is to stop a race condition with how we + // calculate entitlements in tests. + for _, addon := range claims.Addons { + validationErrors := addon.ValidateDependencies(entitlements.Features) + if len(validationErrors) > 0 { + entitlements.Errors = append( + entitlements.Errors, + validationErrors..., + ) + // Ignore the addon and don't add any features. + continue + } + for _, featureName := range addon.Features() { + if _, exists := addonFeatures[featureName]; !exists { + addonFeatures[featureName] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: enablements[featureName] || featureName.AlwaysEnable(), + } + } + } + } + for featureName, feature := range addonFeatures { + entitlements.AddFeature(featureName, feature) + } } // Now the license specific warnings and errors are added to the entitlements. @@ -666,11 +705,12 @@ type Claims struct { 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"` - Features Features `json:"features"` - RequireTelemetry bool `json:"require_telemetry,omitempty"` - PublishUsageData bool `json:"publish_usage_data,omitempty"` + AllFeatures bool `json:"all_features,omitempty"` + Version uint64 `json:"version"` + Features Features `json:"features"` + Addons []codersdk.Addon `json:"addons,omitempty"` + RequireTelemetry bool `json:"require_telemetry,omitempty"` + PublishUsageData bool `json:"publish_usage_data,omitempty"` } var _ jwt.Claims = &Claims{} diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index fcb0817b1e..1ab7ffbcc0 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -188,16 +188,15 @@ func TestEntitlements(t *testing.T) { graceDate := dbtime.Now().AddDate(0, 0, 1) _, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - // TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license. Features: license.Features{ - codersdk.FeatureUserLimit: 100, - codersdk.FeatureAuditLog: 1, - codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, - FeatureSet: codersdk.FeatureSetPremium, GraceAt: graceDate, ExpiresAt: dbtime.Now().AddDate(0, 0, 5), + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, }), Exp: time.Now().AddDate(0, 0, 5), }) @@ -216,17 +215,16 @@ func TestEntitlements(t *testing.T) { // license expires. _, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - // TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license. Features: license.Features{ - codersdk.FeatureUserLimit: 100, - codersdk.FeatureAuditLog: 1, - codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, - FeatureSet: codersdk.FeatureSetPremium, NotBefore: graceDate.Add(-time.Hour), // contiguous, and also in the future GraceAt: dbtime.Now().AddDate(1, 0, 0), ExpiresAt: dbtime.Now().AddDate(1, 0, 5), + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, }), Exp: dbtime.Now().AddDate(1, 0, 5), }) @@ -249,16 +247,15 @@ func TestEntitlements(t *testing.T) { graceDate := dbtime.Now().AddDate(0, 0, 1) _, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - // TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license. Features: license.Features{ - codersdk.FeatureUserLimit: 100, - codersdk.FeatureAuditLog: 1, - codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, - FeatureSet: codersdk.FeatureSetPremium, GraceAt: graceDate, ExpiresAt: dbtime.Now().AddDate(0, 0, 5), + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, }), Exp: time.Now().AddDate(0, 0, 5), }) @@ -277,17 +274,16 @@ func TestEntitlements(t *testing.T) { // license expires (e.g. there's a gap) _, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - // TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license. Features: license.Features{ - codersdk.FeatureUserLimit: 100, - codersdk.FeatureAuditLog: 1, - codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, - FeatureSet: codersdk.FeatureSetPremium, NotBefore: graceDate.Add(time.Minute), // gap of 1 second! GraceAt: dbtime.Now().AddDate(1, 0, 0), ExpiresAt: dbtime.Now().AddDate(1, 0, 5), + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, }), Exp: dbtime.Now().AddDate(1, 0, 5), }) @@ -374,9 +370,15 @@ func TestEntitlements(t *testing.T) { require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) for _, featureName := range codersdk.FeatureNames { - if featureName == codersdk.FeatureUserLimit || featureName == codersdk.FeatureHighAvailability || featureName == codersdk.FeatureMultipleExternalAuth || featureName == codersdk.FeatureManagedAgentLimit { + if featureName == codersdk.FeatureUserLimit || + featureName == codersdk.FeatureHighAvailability || + featureName == codersdk.FeatureMultipleExternalAuth || + featureName == codersdk.FeatureManagedAgentLimit || + featureName == codersdk.FeatureAIGovernanceUserLimit || + featureName == codersdk.FeatureBoundary { // These fields don't generate warnings when not entitled unless - // a limit is breached. + // a limit is breached, or in the case of AI Governance features, + // they require the AI Governance addon. continue } niceName := featureName.Humanize() @@ -515,6 +517,9 @@ func TestEntitlements(t *testing.T) { // Enterprise licenses don't get any agents by default. continue } + if featureName.IsAddonFeature() { + continue + } if slices.Contains(enterpriseFeatures, featureName) { require.True(t, entitlements.Features[featureName].Enabled, featureName) require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) @@ -574,6 +579,9 @@ func TestEntitlements(t *testing.T) { require.WithinDuration(t, agentUsagePeriodEnd, agentEntitlement.UsagePeriod.End, time.Second) continue } + if featureName.IsAddonFeature() { + continue + } if slices.Contains(enterpriseFeatures, featureName) { require.True(t, entitlements.Features[featureName].Enabled, featureName) @@ -627,6 +635,9 @@ func TestEntitlements(t *testing.T) { if featureName.UsesLimit() { continue } + if featureName.IsAddonFeature() { + continue + } if slices.Contains(enterpriseFeatures, featureName) { require.True(t, entitlements.Features[featureName].Enabled, featureName) require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) @@ -690,6 +701,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureUserLimit { continue } + if featureName.IsAddonFeature() { + continue + } if slices.Contains(enterpriseFeatures, featureName) { require.True(t, entitlements.Features[featureName].Enabled, featureName) require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement) @@ -814,16 +828,15 @@ func TestEntitlements(t *testing.T) { ctrl := gomock.NewController(t) mDB := dbmock.NewMockStore(ctrl) - // TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license. licenseOpts := (&coderdenttest.LicenseOptions{ FeatureSet: codersdk.FeatureSetPremium, IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second), NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second), GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning - // Explicit AI Bridge to avoid soft warning in tests + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, Features: license.Features{ - codersdk.FeatureAIBridge: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, }). UserLimit(100). @@ -868,6 +881,7 @@ func TestEntitlements(t *testing.T) { managedAgentLimit, ok := entitlements.Features[codersdk.FeatureManagedAgentLimit] require.True(t, ok) + require.NotNil(t, managedAgentLimit.SoftLimit) require.EqualValues(t, 100, *managedAgentLimit.SoftLimit) require.NotNil(t, managedAgentLimit.Limit) @@ -906,9 +920,9 @@ func TestLicenseEntitlements(t *testing.T) { codersdk.FeatureControlSharedPorts: true, codersdk.FeatureWorkspaceExternalAgent: true, codersdk.FeatureAIBridge: true, + codersdk.FeatureBoundary: true, } - // TODO: Remove explicit FeatureAIBridge from these license helpers once AI Bridge is enforced as an add-on license. legacyLicense := func() *coderdenttest.LicenseOptions { return (&coderdenttest.LicenseOptions{ AccountType: "salesforce", @@ -916,9 +930,9 @@ func TestLicenseEntitlements(t *testing.T) { Trial: false, // Use the legacy boolean AllFeatures: true, - // Explicit AI Bridge to avoid soft warning in tests + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, Features: license.Features{ - codersdk.FeatureAIBridge: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, }).Valid(time.Now()) } @@ -931,9 +945,9 @@ func TestLicenseEntitlements(t *testing.T) { Trial: false, FeatureSet: codersdk.FeatureSetEnterprise, AllFeatures: true, - // Explicit AI Bridge to avoid soft warning in tests + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, Features: license.Features{ - codersdk.FeatureAIBridge: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, }).Valid(time.Now()) } @@ -946,9 +960,9 @@ func TestLicenseEntitlements(t *testing.T) { Trial: false, FeatureSet: codersdk.FeatureSetPremium, AllFeatures: true, - // Explicit AI Bridge to avoid soft warning in tests + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, Features: license.Features{ - codersdk.FeatureAIBridge: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, }, }).Valid(time.Now()) } @@ -1324,74 +1338,9 @@ func TestAIBridgeSoftWarning(t *testing.T) { aiBridgeWarningMessage := "AI Bridge is now Generally Available in v2.30. In a future Coder version, your deployment will require the AI Governance Add-On to continue using this feature. Please reach out to your account team or sales@coder.com to learn more." - t.Run("PremiumLicenseWithAIBridgeEnabled", func(t *testing.T) { + t.Run("NoAddon_AIBridgeOff", func(t *testing.T) { t.Parallel() - // Premium license with AI Bridge enabled should show soft warning - // because AI Bridge is not explicitly granted via add-on. - lo := (&coderdenttest.LicenseOptions{ - AccountType: "salesforce", - AccountID: "test", - FeatureSet: codersdk.FeatureSetPremium, - }).Valid(time.Now()) - - generatedLicenses := []database.License{ - { - ID: 1, - UploadedAt: time.Now().Add(time.Hour * -1), - JWT: lo.Generate(t), - Exp: lo.GraceAt, - UUID: uuid.New(), - }, - } - - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) - require.NoError(t, err) - - // AI Bridge should be enabled and entitled. - aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] - assert.True(t, aiBridgeFeature.Enabled) - assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement) - - // Should have the soft warning. - require.Contains(t, entitlements.Warnings, aiBridgeWarningMessage) - }) - - t.Run("ExplicitAIBridgeLicense", func(t *testing.T) { - t.Parallel() - // License with explicit AI Bridge feature (add-on) should NOT show warning. - lo := (&coderdenttest.LicenseOptions{ - AccountType: "salesforce", - AccountID: "test", - Features: license.Features{ - codersdk.FeatureAIBridge: 1, - }, - }).Valid(time.Now()) - - generatedLicenses := []database.License{ - { - ID: 1, - UploadedAt: time.Now().Add(time.Hour * -1), - JWT: lo.Generate(t), - Exp: lo.GraceAt, - UUID: uuid.New(), - }, - } - - entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) - require.NoError(t, err) - - // AI Bridge should be enabled and entitled. - aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] - assert.True(t, aiBridgeFeature.Enabled) - assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement) - - // Should NOT have the soft warning. - require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage) - }) - - t.Run("PremiumLicenseWithAIBridgeDisabled", func(t *testing.T) { - t.Parallel() - // Premium license with AI Bridge NOT enabled should NOT show warning. + // License without addon and AI Bridge disabled should NOT show warning. lo := (&coderdenttest.LicenseOptions{ AccountType: "salesforce", AccountID: "test", @@ -1411,23 +1360,80 @@ func TestAIBridgeSoftWarning(t *testing.T) { entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) require.NoError(t, err) - // AI Bridge should NOT be enabled. aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] assert.False(t, aiBridgeFeature.Enabled) - - // Should NOT have the soft warning. require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage) }) - t.Run("PremiumPlusExplicitAIBridge", func(t *testing.T) { + t.Run("NoAddon_AIBridgeOn", func(t *testing.T) { t.Parallel() - // Premium license PLUS explicit AI Bridge add-on should NOT show warning. + // License without addon and AI Bridge enabled SHOULD show warning. lo := (&coderdenttest.LicenseOptions{ AccountType: "salesforce", AccountID: "test", FeatureSet: codersdk.FeatureSetPremium, + }).Valid(time.Now()) + + generatedLicenses := []database.License{ + { + ID: 1, + UploadedAt: time.Now().Add(time.Hour * -1), + JWT: lo.Generate(t), + Exp: lo.GraceAt, + UUID: uuid.New(), + }, + } + + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + require.NoError(t, err) + + aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + assert.True(t, aiBridgeFeature.Enabled) + assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement) + require.Contains(t, entitlements.Warnings, aiBridgeWarningMessage) + }) + + t.Run("Addon_AIBridgeOff", func(t *testing.T) { + t.Parallel() + // License with addon and AI Bridge disabled should NOT show warning. + lo := (&coderdenttest.LicenseOptions{ + AccountType: "salesforce", + AccountID: "test", + FeatureSet: codersdk.FeatureSetPremium, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, Features: license.Features{ - codersdk.FeatureAIBridge: 1, + codersdk.FeatureAIGovernanceUserLimit: 100, + }, + }).Valid(time.Now()) + + generatedLicenses := []database.License{ + { + ID: 1, + UploadedAt: time.Now().Add(time.Hour * -1), + JWT: lo.Generate(t), + Exp: lo.GraceAt, + UUID: uuid.New(), + }, + } + + entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) + require.NoError(t, err) + + aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + assert.False(t, aiBridgeFeature.Enabled) + require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage) + }) + + t.Run("Addon_AIBridgeOn", func(t *testing.T) { + t.Parallel() + // License with addon and AI Bridge enabled should NOT show warning. + lo := (&coderdenttest.LicenseOptions{ + AccountType: "salesforce", + AccountID: "test", + FeatureSet: codersdk.FeatureSetPremium, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + Features: license.Features{ + codersdk.FeatureAIGovernanceUserLimit: 100, }, }).Valid(time.Now()) @@ -1444,27 +1450,21 @@ func TestAIBridgeSoftWarning(t *testing.T) { entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) require.NoError(t, err) - // AI Bridge should be enabled and entitled. aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] assert.True(t, aiBridgeFeature.Enabled) assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement) - - // Should NOT have the soft warning. require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage) }) - t.Run("NoLicenseWithAIBridgeEnabled", func(t *testing.T) { + t.Run("NoLicense_AIBridgeOn", func(t *testing.T) { t.Parallel() // No license with AI Bridge enabled should NOT show the soft warning // (it will show the generic "not entitled" warning instead). entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{}) require.NoError(t, err) - // AI Bridge should NOT be entitled. aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] assert.Equal(t, codersdk.EntitlementNotEntitled, aiBridgeFeature.Entitlement) - - // Should NOT have the soft warning (the feature is not entitled so it won't be enabled). require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage) }) } @@ -1829,6 +1829,186 @@ func TestManagedAgentLimitDefault(t *testing.T) { }) } +func TestAIGovernanceAddon(t *testing.T) { + t.Parallel() + + empty := map[codersdk.FeatureName]bool{} + + t.Run("AIGovernanceAddon enables AI governance features when enablements are set", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + Features: license.Features{ + codersdk.FeatureAIGovernanceUserLimit: 1000, + codersdk.FeatureManagedAgentLimit: 1000, + }, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + }), + Exp: dbtime.Now().Add(time.Hour), + }) + + // Enable AI governance features in enablements. + enablements := map[codersdk.FeatureName]bool{ + codersdk.FeatureAIBridge: true, + codersdk.FeatureBoundary: true, + } + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // AI Bridge should be enabled without warning when addon is present. + aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + require.True(t, aibridgeFeature.Enabled, "AI Bridge should be enabled when addon is present and enablements are set") + aiBridgeWarningMessage := "AI Bridge is now Generally Available in v2.30. In a future Coder version, your deployment will require the AI Governance Add-On to continue using this feature. Please reach out to your account team or sales@coder.com to learn more." + require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage, "AI Bridge warning should not appear when AI Governance addon is present") + + // require.Equal(t, codersdk.EntitlementEntitled, aibridgeFeature.Entitlement, "AI Bridge should be entitled when addon is present") + + // TODO: Readd this test once Boundary is enforced as an add-on license. + // boundaryFeature := entitlements.Features[codersdk.FeatureBoundary] + // require.True(t, boundaryFeature.Enabled, "Boundary should be enabled when addon is present and enablements are set") + // require.Equal(t, codersdk.EntitlementEntitled, boundaryFeature.Entitlement, "Boundary should be entitled when addon is present") + }) + + t.Run("AIGovernanceAddon not present disables AI governance features", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + }), + Exp: dbtime.Now().Add(time.Hour), + }) + + enablements := map[codersdk.FeatureName]bool{ + codersdk.FeatureAIBridge: true, + codersdk.FeatureBoundary: true, + } + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // TODO: Readd this test once AI Bridge is enforced as an add-on license. + // AI Bridge should not be entitled. + // aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + // require.False(t, aibridgeFeature.Enabled, "AI Bridge should not be enabled when addon is absent") + // require.Equal(t, codersdk.EntitlementNotEntitled, aibridgeFeature.Entitlement, "AI Bridge should not be entitled when addon is absent") + + // TODO: Readd this test once Boundary is enforced as an add-on license. + // boundaryFeature := entitlements.Features[codersdk.FeatureBoundary] + // require.False(t, boundaryFeature.Enabled, "Boundary should not be enabled when addon is absent") + // require.Equal(t, codersdk.EntitlementNotEntitled, boundaryFeature.Entitlement, "Boundary should not be entitled when addon is absent") + }) + + t.Run("AIGovernanceAddon respects grace period entitlement", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + Features: license.Features{ + codersdk.FeatureAIGovernanceUserLimit: 1000, + codersdk.FeatureManagedAgentLimit: 1000, + }, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + NotBefore: dbtime.Now().Add(-time.Hour * 2), + GraceAt: dbtime.Now().Add(-time.Hour), + ExpiresAt: dbtime.Now().Add(time.Hour), + }), + Exp: dbtime.Now().Add(time.Hour), + }) + + enablements := map[codersdk.FeatureName]bool{ + codersdk.FeatureAIBridge: true, + codersdk.FeatureBoundary: true, + } + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // TODO: Readd this test once AI Bridge is enforced as an add-on license. + // AI governance features should be enabled but in grace period. + // aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + // require.True(t, aibridgeFeature.Enabled, "AI Bridge should be enabled during grace period") + // require.Equal(t, codersdk.EntitlementGracePeriod, aibridgeFeature.Entitlement, "AI Bridge should be in grace period") + + // TODO: Readd this test once Boundary is enforced as an add-on license. + // boundaryFeature := entitlements.Features[codersdk.FeatureBoundary] + // require.True(t, boundaryFeature.Enabled, "Boundary should be enabled during grace period") + // require.Equal(t, codersdk.EntitlementGracePeriod, boundaryFeature.Entitlement, "Boundary should be in grace period") + }) + + t.Run("AIGovernanceAddon requires enablements to enable features", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + Features: license.Features{ + codersdk.FeatureAIGovernanceUserLimit: 1000, + codersdk.FeatureManagedAgentLimit: 1000, + }, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + }), + Exp: dbtime.Now().Add(time.Hour), + }) + + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, empty) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // TODO: Readd this test once AI Bridge is enforced as an add-on license. + // aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + // require.False(t, aibridgeFeature.Enabled, "AI Bridge should not be enabled without enablements") + // require.Equal(t, codersdk.EntitlementEntitled, aibridgeFeature.Entitlement, "AI Bridge should still be entitled") + + // TODO: Readd this test once Boundary is enforced as an add-on license. + // boundaryFeature := entitlements.Features[codersdk.FeatureBoundary] + // require.False(t, boundaryFeature.Enabled, "Boundary should not be enabled without enablements") + // require.Equal(t, codersdk.EntitlementEntitled, boundaryFeature.Entitlement, "Boundary should still be entitled") + }) + + t.Run("AIGovernanceAddon missing dependencies", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + // Use Enterprise so ManagedAgentLimit doesn't get default value, and + // don't set either dependency. + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetEnterprise, + Features: license.Features{}, + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + }), + Exp: dbtime.Now().Add(time.Hour), + }) + + enablements := map[codersdk.FeatureName]bool{ + codersdk.FeatureAIBridge: true, + codersdk.FeatureBoundary: true, + } + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, enablements) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // Should have validation error for missing AI Governance User Limit. + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "Feature AI Governance User Limit must be set when using the AI Governance addon.", entitlements.Errors[0]) + + // TODO: Readd this test once AI Bridge is enforced as an add-on license. + // AI governance features should not be entitled when validation fails. + // aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge] + // require.False(t, aibridgeFeature.Enabled, "AI Bridge should not be enabled when addon validation fails") + // require.Equal(t, codersdk.EntitlementNotEntitled, aibridgeFeature.Entitlement, "AI Bridge should not be entitled when addon validation fails") + + // TODO: Readd this test once Boundary is enforced as an add-on license. + // boundaryFeature := entitlements.Features[codersdk.FeatureBoundary] + // require.False(t, boundaryFeature.Enabled, "Boundary should not be enabled when addon validation fails") + // require.Equal(t, codersdk.EntitlementNotEntitled, boundaryFeature.Entitlement, "Boundary should not be entitled when addon validation fails") + }) +} + func assertNoErrors(t *testing.T, entitlements codersdk.Entitlements) { t.Helper() assert.Empty(t, entitlements.Errors, "no errors") diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index da26b8e1de..b82983b0e2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -585,6 +585,11 @@ export interface AddLicenseRequest { readonly license: string; } +// From codersdk/deployment.go +export type Addon = "ai_governance"; + +export const Addons: Addon[] = ["ai_governance"]; + // From codersdk/workspacebuilds.go export interface AgentConnectionTiming { readonly started_at: string; @@ -2107,6 +2112,7 @@ export interface Feature { // From codersdk/deployment.go export type FeatureName = | "aibridge" + | "ai_governance_user_limit" | "access_control" | "advanced_template_scheduling" | "appearance" @@ -2134,6 +2140,7 @@ export type FeatureName = export const FeatureNames: FeatureName[] = [ "aibridge", + "ai_governance_user_limit", "access_control", "advanced_template_scheduling", "appearance",