Skip to content

Commit 915bb41

Browse files
authored
feat: Add trial property to licenses (#4372)
* feat: Add trial property to licenses This allows the frontend to display whether the user is on a trial license of Coder. This is useful for advertising Enterprise functionality. * Improve tests for license enablement code * Add all features property
1 parent 05670d1 commit 915bb41

File tree

16 files changed

+504
-329
lines changed

16 files changed

+504
-329
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"cliflag",
1010
"cliui",
1111
"codecov",
12-
"Codespaces",
1312
"coderd",
13+
"coderdenttest",
1414
"coderdtest",
1515
"codersdk",
1616
"cronstrue",
@@ -24,6 +24,7 @@
2424
"drpcmux",
2525
"drpcserver",
2626
"Dsts",
27+
"enablements",
2728
"fatih",
2829
"Formik",
2930
"gitsshkey",

codersdk/features.go

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Entitlements struct {
4242
Warnings []string `json:"warnings"`
4343
HasLicense bool `json:"has_license"`
4444
Experimental bool `json:"experimental"`
45+
Trial bool `json:"trial"`
4546
}
4647

4748
func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {

enterprise/cli/features_test.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) {
5757
var entitlements codersdk.Entitlements
5858
err := json.Unmarshal(buf.Bytes(), &entitlements)
5959
require.NoError(t, err, "unmarshal JSON output")
60-
assert.Len(t, entitlements.Features, 4)
60+
assert.Len(t, entitlements.Features, 5)
6161
assert.Empty(t, entitlements.Warnings)
6262
assert.Equal(t, codersdk.EntitlementNotEntitled,
6363
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
@@ -67,6 +67,8 @@ func TestFeaturesList(t *testing.T) {
6767
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
6868
assert.Equal(t, codersdk.EntitlementNotEntitled,
6969
entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement)
70+
assert.Equal(t, codersdk.EntitlementNotEntitled,
71+
entitlements.Features[codersdk.FeatureSCIM].Entitlement)
7072
assert.False(t, entitlements.HasLicense)
7173
assert.False(t, entitlements.Experimental)
7274
})

enterprise/coderd/coderd.go

+35-172
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package coderd
33
import (
44
"context"
55
"crypto/ed25519"
6-
"fmt"
76
"net/http"
87
"sync"
98
"time"
@@ -15,11 +14,14 @@ import (
1514

1615
"cdr.dev/slog"
1716
"github.com/coder/coder/coderd"
17+
agplaudit "github.com/coder/coder/coderd/audit"
1818
"github.com/coder/coder/coderd/httpapi"
1919
"github.com/coder/coder/coderd/httpmw"
20+
"github.com/coder/coder/coderd/workspacequota"
2021
"github.com/coder/coder/codersdk"
2122
"github.com/coder/coder/enterprise/audit"
2223
"github.com/coder/coder/enterprise/audit/backends"
24+
"github.com/coder/coder/enterprise/coderd/license"
2325
)
2426

2527
// New constructs an Enterprise coderd API instance.
@@ -34,19 +36,8 @@ func New(ctx context.Context, options *Options) (*API, error) {
3436
}
3537
ctx, cancelFunc := context.WithCancel(ctx)
3638
api := &API{
37-
AGPL: coderd.New(options.Options),
38-
Options: options,
39-
40-
entitlements: entitlements{
41-
activeUsers: codersdk.Feature{
42-
Entitlement: codersdk.EntitlementNotEntitled,
43-
Enabled: false,
44-
},
45-
auditLogs: codersdk.EntitlementNotEntitled,
46-
browserOnly: codersdk.EntitlementNotEntitled,
47-
scim: codersdk.EntitlementNotEntitled,
48-
workspaceQuota: codersdk.EntitlementNotEntitled,
49-
},
39+
AGPL: coderd.New(options.Options),
40+
Options: options,
5041
cancelEntitlementsLoop: cancelFunc,
5142
}
5243
oauthConfigs := &httpmw.OAuth2Configs{
@@ -117,16 +108,7 @@ type API struct {
117108

118109
cancelEntitlementsLoop func()
119110
entitlementsMu sync.RWMutex
120-
entitlements entitlements
121-
}
122-
123-
type entitlements struct {
124-
hasLicense bool
125-
activeUsers codersdk.Feature
126-
auditLogs codersdk.Entitlement
127-
browserOnly codersdk.Entitlement
128-
scim codersdk.Entitlement
129-
workspaceQuota codersdk.Entitlement
111+
entitlements codersdk.Entitlements
130112
}
131113

132114
func (api *API) Close() error {
@@ -135,94 +117,57 @@ func (api *API) Close() error {
135117
}
136118

137119
func (api *API) updateEntitlements(ctx context.Context) error {
138-
licenses, err := api.Database.GetUnexpiredLicenses(ctx)
139-
if err != nil {
140-
return err
141-
}
142120
api.entitlementsMu.Lock()
143121
defer api.entitlementsMu.Unlock()
144-
now := time.Now()
145122

146-
// Default all entitlements to be disabled.
147-
entitlements := entitlements{
148-
hasLicense: false,
149-
activeUsers: codersdk.Feature{
150-
Enabled: false,
151-
Entitlement: codersdk.EntitlementNotEntitled,
152-
},
153-
auditLogs: codersdk.EntitlementNotEntitled,
154-
scim: codersdk.EntitlementNotEntitled,
155-
browserOnly: codersdk.EntitlementNotEntitled,
156-
workspaceQuota: codersdk.EntitlementNotEntitled,
123+
entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, api.Keys, map[string]bool{
124+
codersdk.FeatureAuditLog: api.AuditLogging,
125+
codersdk.FeatureBrowserOnly: api.BrowserOnly,
126+
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
127+
codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0,
128+
})
129+
if err != nil {
130+
return err
157131
}
158132

159-
// Here we loop through licenses to detect enabled features.
160-
for _, l := range licenses {
161-
claims, err := validateDBLicense(l, api.Keys)
162-
if err != nil {
163-
api.Logger.Debug(ctx, "skipping invalid license",
164-
slog.F("id", l.ID), slog.Error(err))
165-
continue
133+
featureChanged := func(featureName string) (changed bool, enabled bool) {
134+
if api.entitlements.Features == nil {
135+
return true, entitlements.Features[featureName].Enabled
166136
}
167-
entitlements.hasLicense = true
168-
entitlement := codersdk.EntitlementEntitled
169-
if now.After(claims.LicenseExpires.Time) {
170-
// if the grace period were over, the validation fails, so if we are after
171-
// LicenseExpires we must be in grace period.
172-
entitlement = codersdk.EntitlementGracePeriod
173-
}
174-
if claims.Features.UserLimit > 0 {
175-
entitlements.activeUsers = codersdk.Feature{
176-
Enabled: true,
177-
Entitlement: entitlement,
178-
}
179-
currentLimit := int64(0)
180-
if entitlements.activeUsers.Limit != nil {
181-
currentLimit = *entitlements.activeUsers.Limit
182-
}
183-
limit := max(currentLimit, claims.Features.UserLimit)
184-
entitlements.activeUsers.Limit = &limit
185-
}
186-
if claims.Features.AuditLog > 0 {
187-
entitlements.auditLogs = entitlement
188-
}
189-
if claims.Features.BrowserOnly > 0 {
190-
entitlements.browserOnly = entitlement
191-
}
192-
if claims.Features.SCIM > 0 {
193-
entitlements.scim = entitlement
194-
}
195-
if claims.Features.WorkspaceQuota > 0 {
196-
entitlements.workspaceQuota = entitlement
137+
oldFeature := api.entitlements.Features[featureName]
138+
newFeature := entitlements.Features[featureName]
139+
if oldFeature.Enabled != newFeature.Enabled {
140+
return true, newFeature.Enabled
197141
}
142+
return false, newFeature.Enabled
198143
}
199144

200-
if entitlements.auditLogs != api.entitlements.auditLogs {
201-
// A flag could be added to the options that would allow disabling
202-
// enhanced audit logging here!
203-
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
204-
auditor := audit.NewAuditor(
145+
if changed, enabled := featureChanged(codersdk.FeatureAuditLog); changed {
146+
auditor := agplaudit.NewNop()
147+
if enabled {
148+
auditor = audit.NewAuditor(
205149
audit.DefaultFilter,
206150
backends.NewPostgres(api.Database, true),
207151
backends.NewSlog(api.Logger),
208152
)
209-
api.AGPL.Auditor.Store(&auditor)
210153
}
154+
api.AGPL.Auditor.Store(&auditor)
211155
}
212156

213-
if entitlements.browserOnly != api.entitlements.browserOnly {
157+
if changed, enabled := featureChanged(codersdk.FeatureBrowserOnly); changed {
214158
var handler func(rw http.ResponseWriter) bool
215-
if entitlements.browserOnly != codersdk.EntitlementNotEntitled && api.BrowserOnly {
159+
if enabled {
216160
handler = api.shouldBlockNonBrowserConnections
217161
}
218162
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
219163
}
220164

221-
if entitlements.workspaceQuota != api.entitlements.workspaceQuota {
222-
if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.UserWorkspaceQuota > 0 {
223-
enforcer := NewEnforcer(api.Options.UserWorkspaceQuota)
224-
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
165+
if changed, enabled := featureChanged(codersdk.FeatureWorkspaceQuota); changed {
166+
enforcer := workspacequota.NewNop()
167+
if enabled {
168+
enforcer = NewEnforcer(api.Options.UserWorkspaceQuota)
225169
}
170+
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
226171
}
227172

228173
api.entitlements = entitlements
@@ -235,82 +180,7 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
235180
api.entitlementsMu.RLock()
236181
entitlements := api.entitlements
237182
api.entitlementsMu.RUnlock()
238-
239-
resp := codersdk.Entitlements{
240-
Features: make(map[string]codersdk.Feature),
241-
Warnings: make([]string, 0),
242-
HasLicense: entitlements.hasLicense,
243-
Experimental: api.Experimental,
244-
}
245-
246-
if entitlements.activeUsers.Limit != nil {
247-
activeUserCount, err := api.Database.GetActiveUserCount(ctx)
248-
if err != nil {
249-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
250-
Message: "Unable to query database",
251-
Detail: err.Error(),
252-
})
253-
return
254-
}
255-
entitlements.activeUsers.Actual = &activeUserCount
256-
if activeUserCount > *entitlements.activeUsers.Limit {
257-
resp.Warnings = append(resp.Warnings,
258-
fmt.Sprintf(
259-
"Your deployment has %d active users but is only licensed for %d.",
260-
activeUserCount, *entitlements.activeUsers.Limit))
261-
}
262-
}
263-
resp.Features[codersdk.FeatureUserLimit] = entitlements.activeUsers
264-
265-
// Audit logs
266-
resp.Features[codersdk.FeatureAuditLog] = codersdk.Feature{
267-
Entitlement: entitlements.auditLogs,
268-
Enabled: api.AuditLogging,
269-
}
270-
// Audit logging is enabled by default. We don't want to display
271-
// a warning if they don't have a license.
272-
if entitlements.hasLicense && api.AuditLogging {
273-
if entitlements.auditLogs == codersdk.EntitlementNotEntitled {
274-
resp.Warnings = append(resp.Warnings,
275-
"Audit logging is enabled but your license is not entitled to this feature.")
276-
}
277-
if entitlements.auditLogs == codersdk.EntitlementGracePeriod {
278-
resp.Warnings = append(resp.Warnings,
279-
"Audit logging is enabled but your license for this feature is expired.")
280-
}
281-
}
282-
283-
resp.Features[codersdk.FeatureBrowserOnly] = codersdk.Feature{
284-
Entitlement: entitlements.browserOnly,
285-
Enabled: api.BrowserOnly,
286-
}
287-
if api.BrowserOnly {
288-
if entitlements.browserOnly == codersdk.EntitlementNotEntitled {
289-
resp.Warnings = append(resp.Warnings,
290-
"Browser only connections are enabled but your license is not entitled to this feature.")
291-
}
292-
if entitlements.browserOnly == codersdk.EntitlementGracePeriod {
293-
resp.Warnings = append(resp.Warnings,
294-
"Browser only connections are enabled but your license for this feature is expired.")
295-
}
296-
}
297-
298-
resp.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{
299-
Entitlement: entitlements.workspaceQuota,
300-
Enabled: api.UserWorkspaceQuota > 0,
301-
}
302-
if api.UserWorkspaceQuota > 0 {
303-
if entitlements.workspaceQuota == codersdk.EntitlementNotEntitled {
304-
resp.Warnings = append(resp.Warnings,
305-
"Workspace quotas are enabled but your license is not entitled to this feature.")
306-
}
307-
if entitlements.workspaceQuota == codersdk.EntitlementGracePeriod {
308-
resp.Warnings = append(resp.Warnings,
309-
"Workspace quotas are enabled but your license for this feature is expired.")
310-
}
311-
}
312-
313-
httpapi.Write(ctx, rw, http.StatusOK, resp)
183+
httpapi.Write(ctx, rw, http.StatusOK, entitlements)
314184
}
315185

316186
func (api *API) runEntitlementsLoop(ctx context.Context) {
@@ -374,10 +244,3 @@ func (api *API) runEntitlementsLoop(ctx context.Context) {
374244
}
375245
}
376246
}
377-
378-
func max(a, b int64) int64 {
379-
if a > b {
380-
return a
381-
}
382-
return b
383-
}

enterprise/coderd/coderd_test.go

-42
Original file line numberDiff line numberDiff line change
@@ -86,48 +86,6 @@ func TestEntitlements(t *testing.T) {
8686
assert.Equal(t, codersdk.EntitlementNotEntitled, al.Entitlement)
8787
assert.True(t, al.Enabled)
8888
})
89-
t.Run("Warnings", func(t *testing.T) {
90-
t.Parallel()
91-
client := coderdenttest.New(t, &coderdenttest.Options{
92-
AuditLogging: true,
93-
BrowserOnly: true,
94-
})
95-
first := coderdtest.CreateFirstUser(t, client)
96-
for i := 0; i < 4; i++ {
97-
coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
98-
}
99-
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
100-
UserLimit: 4,
101-
AuditLog: true,
102-
BrowserOnly: true,
103-
GraceAt: time.Now().Add(-time.Second),
104-
})
105-
res, err := client.Entitlements(context.Background())
106-
require.NoError(t, err)
107-
assert.True(t, res.HasLicense)
108-
ul := res.Features[codersdk.FeatureUserLimit]
109-
assert.Equal(t, codersdk.EntitlementGracePeriod, ul.Entitlement)
110-
assert.Equal(t, int64(4), *ul.Limit)
111-
assert.Equal(t, int64(5), *ul.Actual)
112-
assert.True(t, ul.Enabled)
113-
al := res.Features[codersdk.FeatureAuditLog]
114-
assert.Equal(t, codersdk.EntitlementGracePeriod, al.Entitlement)
115-
assert.True(t, al.Enabled)
116-
assert.Nil(t, al.Limit)
117-
assert.Nil(t, al.Actual)
118-
bo := res.Features[codersdk.FeatureBrowserOnly]
119-
assert.Equal(t, codersdk.EntitlementGracePeriod, bo.Entitlement)
120-
assert.True(t, bo.Enabled)
121-
assert.Nil(t, bo.Limit)
122-
assert.Nil(t, bo.Actual)
123-
assert.Len(t, res.Warnings, 3)
124-
assert.Contains(t, res.Warnings,
125-
"Your deployment has 5 active users but is only licensed for 4.")
126-
assert.Contains(t, res.Warnings,
127-
"Audit logging is enabled but your license for this feature is expired.")
128-
assert.Contains(t, res.Warnings,
129-
"Browser only connections are enabled but your license for this feature is expired.")
130-
})
13189
t.Run("Pubsub", func(t *testing.T) {
13290
t.Parallel()
13391
client, _, api := coderdenttest.NewWithAPI(t, nil)

0 commit comments

Comments
 (0)