Skip to content

Commit c9bce19

Browse files
authored
GET license endpoint (#3651)
* GET license endpoint Signed-off-by: Spike Curtis <spike@coder.com> * SDK GetLicenses -> Licenses Signed-off-by: Spike Curtis <spike@coder.com> Signed-off-by: Spike Curtis <spike@coder.com>
1 parent da54874 commit c9bce19

13 files changed

+223
-11
lines changed

coderd/authorize.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import (
1010
"github.com/coder/coder/coderd/rbac"
1111
)
1212

13-
func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
13+
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
1414
roles := httpmw.AuthorizationUserRoles(r)
15-
objects, err := rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects)
15+
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, action, objects)
1616
if err != nil {
1717
// Log the error as Filter should not be erroring.
18-
api.Logger.Error(r.Context(), "filter failed",
18+
h.Logger.Error(r.Context(), "filter failed",
1919
slog.Error(err),
2020
slog.F("user_id", roles.ID),
2121
slog.F("username", roles.Username),

coderd/database/databasefake/databasefake.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2278,8 +2278,8 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) {
22782278

22792279
func (q *fakeQuerier) InsertLicense(
22802280
_ context.Context, arg database.InsertLicenseParams) (database.License, error) {
2281-
q.mutex.RLock()
2282-
defer q.mutex.RUnlock()
2281+
q.mutex.Lock()
2282+
defer q.mutex.Unlock()
22832283

22842284
l := database.License{
22852285
ID: q.lastLicenseID + 1,
@@ -2292,6 +2292,15 @@ func (q *fakeQuerier) InsertLicense(
22922292
return l, nil
22932293
}
22942294

2295+
func (q *fakeQuerier) GetLicenses(_ context.Context) ([]database.License, error) {
2296+
q.mutex.RLock()
2297+
defer q.mutex.RUnlock()
2298+
2299+
results := append([]database.License{}, q.licenses...)
2300+
sort.Slice(results, func(i, j int) bool { return results[i].ID < results[j].ID })
2301+
return results, nil
2302+
}
2303+
22952304
func (q *fakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {
22962305
q.mutex.RLock()
22972306
defer q.mutex.RUnlock()

coderd/database/modelmethods.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ func (f File) RBACObject() rbac.Object {
4343
func (User) RBACObject() rbac.Object {
4444
return rbac.ResourceUser
4545
}
46+
47+
func (License) RBACObject() rbac.Object {
48+
return rbac.ResourceLicense
49+
}

coderd/database/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/licenses.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@ INSERT INTO
77
)
88
VALUES
99
($1, $2, $3) RETURNING *;
10+
11+
12+
-- name: GetLicenses :many
13+
SELECT *
14+
FROM licenses
15+
ORDER BY (id);

coderd/provisionerdaemons.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
5050
if daemons == nil {
5151
daemons = []database.ProvisionerDaemon{}
5252
}
53-
daemons, err = AuthorizeFilter(api, r, rbac.ActionRead, daemons)
53+
daemons, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, daemons)
5454
if err != nil {
5555
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
5656
Message: "Internal error fetching provisioner daemons.",

coderd/templates.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
292292
}
293293

294294
// Filter templates based on rbac permissions
295-
templates, err = AuthorizeFilter(api, r, rbac.ActionRead, templates)
295+
templates, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, templates)
296296
if err != nil {
297297
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
298298
Message: "Internal error fetching templates.",

coderd/users.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
158158
return
159159
}
160160

161-
users, err = AuthorizeFilter(api, r, rbac.ActionRead, users)
161+
users, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, users)
162162
if err != nil {
163163
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
164164
Message: "Internal error fetching users.",
@@ -503,7 +503,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
503503
}
504504

505505
// Only include ones we can read from RBAC.
506-
memberships, err = AuthorizeFilter(api, r, rbac.ActionRead, memberships)
506+
memberships, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, memberships)
507507
if err != nil {
508508
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
509509
Message: "Internal error fetching memberships.",
@@ -631,7 +631,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
631631
}
632632

633633
// Only return orgs the user can read.
634-
organizations, err = AuthorizeFilter(api, r, rbac.ActionRead, organizations)
634+
organizations, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, organizations)
635635
if err != nil {
636636
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
637637
Message: "Internal error fetching organizations.",

coderd/workspaces.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
143143
}
144144

145145
// Only return workspaces the user can read
146-
workspaces, err = AuthorizeFilter(api, r, rbac.ActionRead, workspaces)
146+
workspaces, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, workspaces)
147147
if err != nil {
148148
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
149149
Message: "Internal error fetching workspaces.",

codersdk/licenses.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,18 @@ func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License,
3535
d.UseNumber()
3636
return l, d.Decode(&l)
3737
}
38+
39+
func (c *Client) Licenses(ctx context.Context) ([]License, error) {
40+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/licenses", nil)
41+
if err != nil {
42+
return nil, err
43+
}
44+
defer res.Body.Close()
45+
if res.StatusCode != http.StatusOK {
46+
return nil, readBodyAsError(res)
47+
}
48+
var licenses []License
49+
d := json.NewDecoder(res.Body)
50+
d.UseNumber()
51+
return licenses, d.Decode(&licenses)
52+
}

enterprise/coderd/licenses.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package coderd
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/ed25519"
7+
"database/sql"
68
_ "embed"
9+
"encoding/base64"
10+
"encoding/json"
711
"net/http"
12+
"strings"
813
"time"
914

1015
"golang.org/x/xerrors"
@@ -119,6 +124,7 @@ func newLicenseAPI(
119124
r := chi.NewRouter()
120125
a := &licenseAPI{router: r, logger: l, database: db, pubsub: ps, auth: auth}
121126
r.Post("/", a.postLicense)
127+
r.Get("/", a.licenses)
122128
return a
123129
}
124130

@@ -192,3 +198,70 @@ func convertLicense(dl database.License, c jwt.MapClaims) codersdk.License {
192198
Claims: c,
193199
}
194200
}
201+
202+
func (a *licenseAPI) licenses(rw http.ResponseWriter, r *http.Request) {
203+
licenses, err := a.database.GetLicenses(r.Context())
204+
if xerrors.Is(err, sql.ErrNoRows) {
205+
httpapi.Write(rw, http.StatusOK, []codersdk.License{})
206+
return
207+
}
208+
if err != nil {
209+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
210+
Message: "Internal error fetching licenses.",
211+
Detail: err.Error(),
212+
})
213+
return
214+
}
215+
216+
licenses, err = coderd.AuthorizeFilter(a.auth, r, rbac.ActionRead, licenses)
217+
if err != nil {
218+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
219+
Message: "Internal error fetching licenses.",
220+
Detail: err.Error(),
221+
})
222+
return
223+
}
224+
sdkLicenses, err := convertLicenses(licenses)
225+
if err != nil {
226+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
227+
Message: "Internal error parsing licenses.",
228+
Detail: err.Error(),
229+
})
230+
return
231+
}
232+
httpapi.Write(rw, http.StatusOK, sdkLicenses)
233+
}
234+
235+
func convertLicenses(licenses []database.License) ([]codersdk.License, error) {
236+
var out []codersdk.License
237+
for _, l := range licenses {
238+
c, err := decodeClaims(l)
239+
if err != nil {
240+
return nil, err
241+
}
242+
out = append(out, convertLicense(l, c))
243+
}
244+
return out, nil
245+
}
246+
247+
// decodeClaims decodes the JWT claims from the stored JWT. Note here we do not validate the JWT
248+
// and just return the claims verbatim. We want to include all licenses on the GET response, even
249+
// if they are expired, or signed by a key this version of Coder no longer considers valid.
250+
//
251+
// Also, we do not return the whole JWT itself because a signed JWT is a bearer token and we
252+
// want to limit the chance of it being accidentally leaked.
253+
func decodeClaims(l database.License) (jwt.MapClaims, error) {
254+
parts := strings.Split(l.JWT, ".")
255+
if len(parts) != 3 {
256+
return nil, xerrors.Errorf("Unable to parse license %d as JWT", l.ID)
257+
}
258+
cb, err := base64.URLEncoding.DecodeString(parts[1])
259+
if err != nil {
260+
return nil, xerrors.Errorf("Unable to decode license %d claims: %w", l.ID, err)
261+
}
262+
c := make(jwt.MapClaims)
263+
d := json.NewDecoder(bytes.NewBuffer(cb))
264+
d.UseNumber()
265+
err = d.Decode(&c)
266+
return c, err
267+
}

enterprise/coderd/licenses_internal_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,76 @@ func TestPostLicense(t *testing.T) {
142142
})
143143
}
144144

145+
// these tests patch the map of license keys, so cannot be run in parallel
146+
// nolint:paralleltest
147+
func TestGetLicense(t *testing.T) {
148+
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
149+
require.NoError(t, err)
150+
keyID := "testing"
151+
oldKeys := keys
152+
defer func() {
153+
t.Log("restoring keys")
154+
keys = oldKeys
155+
}()
156+
keys = map[string]ed25519.PublicKey{keyID: pubKey}
157+
158+
t.Run("GET", func(t *testing.T) {
159+
client := coderdtest.New(t, &coderdtest.Options{APIBuilder: NewEnterprise})
160+
_ = coderdtest.CreateFirstUser(t, client)
161+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
162+
defer cancel()
163+
164+
claims := &Claims{
165+
RegisteredClaims: jwt.RegisteredClaims{
166+
Issuer: "test@coder.test",
167+
IssuedAt: jwt.NewNumericDate(time.Now()),
168+
NotBefore: jwt.NewNumericDate(time.Now()),
169+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
170+
},
171+
LicenseExpires: jwt.NewNumericDate(time.Now().Add(time.Hour)),
172+
AccountType: AccountTypeSalesforce,
173+
AccountID: "testing",
174+
Version: CurrentVersion,
175+
Features: Features{
176+
UserLimit: 0,
177+
AuditLog: 1,
178+
},
179+
}
180+
lic, err := makeLicense(claims, privKey, keyID)
181+
require.NoError(t, err)
182+
_, err = client.AddLicense(ctx, codersdk.AddLicenseRequest{
183+
License: lic,
184+
})
185+
require.NoError(t, err)
186+
187+
// 2nd license
188+
claims.AccountID = "testing2"
189+
claims.Features.UserLimit = 200
190+
lic2, err := makeLicense(claims, privKey, keyID)
191+
require.NoError(t, err)
192+
_, err = client.AddLicense(ctx, codersdk.AddLicenseRequest{
193+
License: lic2,
194+
})
195+
require.NoError(t, err)
196+
197+
licenses, err := client.Licenses(ctx)
198+
require.NoError(t, err)
199+
require.Len(t, licenses, 2)
200+
assert.Equal(t, int32(1), licenses[0].ID)
201+
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
202+
assert.Equal(t, map[string]interface{}{
203+
codersdk.FeatureUserLimit: json.Number("0"),
204+
codersdk.FeatureAuditLog: json.Number("1"),
205+
}, licenses[0].Claims["features"])
206+
assert.Equal(t, int32(2), licenses[1].ID)
207+
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
208+
assert.Equal(t, map[string]interface{}{
209+
codersdk.FeatureUserLimit: json.Number("200"),
210+
codersdk.FeatureAuditLog: json.Number("1"),
211+
}, licenses[1].Claims["features"])
212+
})
213+
}
214+
145215
func makeLicense(c *Claims, privateKey ed25519.PrivateKey, keyID string) (string, error) {
146216
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
147217
tok.Header[HeaderKeyID] = keyID

0 commit comments

Comments
 (0)