From 9642cba453805bc11733993ae6ddf902351909a7 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 12:00:50 -0700 Subject: [PATCH 1/3] GET license endpoint Signed-off-by: Spike Curtis --- coderd/authorize.go | 6 +- coderd/database/databasefake/databasefake.go | 13 +++- coderd/database/dump.sql | 1 - coderd/database/modelmethods.go | 4 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 34 +++++++++ coderd/database/queries/licenses.sql | 6 ++ coderd/provisionerdaemons.go | 2 +- coderd/templates.go | 2 +- coderd/users.go | 6 +- coderd/workspaces.go | 2 +- codersdk/licenses.go | 15 ++++ enterprise/coderd/licenses.go | 73 ++++++++++++++++++++ enterprise/coderd/licenses_internal_test.go | 70 +++++++++++++++++++ 14 files changed, 223 insertions(+), 12 deletions(-) diff --git a/coderd/authorize.go b/coderd/authorize.go index b68e1b68544a3..bd28ea95b989a 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -10,12 +10,12 @@ import ( "github.com/coder/coder/coderd/rbac" ) -func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Action, objects []O) ([]O, error) { +func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) { roles := httpmw.AuthorizationUserRoles(r) - objects, err := rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects) + objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, action, objects) if err != nil { // Log the error as Filter should not be erroring. - api.Logger.Error(r.Context(), "filter failed", + h.Logger.Error(r.Context(), "filter failed", slog.Error(err), slog.F("user_id", roles.ID), slog.F("username", roles.Username), diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 7ba138c820312..dd39eae917260 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2282,8 +2282,8 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) { func (q *fakeQuerier) InsertLicense( _ context.Context, arg database.InsertLicenseParams) (database.License, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() l := database.License{ ID: q.lastLicenseID + 1, @@ -2296,6 +2296,15 @@ func (q *fakeQuerier) InsertLicense( return l, nil } +func (q *fakeQuerier) GetLicenses(_ context.Context) ([]database.License, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + results := append([]database.License{}, q.licenses...) + sort.Slice(results, func(i, j int) bool { return results[i].ID < results[j].ID }) + return results, nil +} + func (q *fakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 8c3e08a6b1995..a262c428d700e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -559,4 +559,3 @@ ALTER TABLE ONLY workspaces ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; - diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 1002ef4b4b523..6df4d67716f7d 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -43,3 +43,7 @@ func (f File) RBACObject() rbac.Object { func (User) RBACObject() rbac.Object { return rbac.ResourceUser } + +func (License) RBACObject() rbac.Object { + return rbac.ResourceLicense +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cddf33e33d427..fc143c6dc05e7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -36,6 +36,7 @@ type querier interface { GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) + GetLicenses(ctx context.Context) ([]License, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3e6643781d30a..bc8a405313b08 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -475,6 +475,40 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar return err } +const getLicenses = `-- name: GetLicenses :many +SELECT id, uploaded_at, jwt, exp +FROM licenses +ORDER BY (id) +` + +func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) { + rows, err := q.db.QueryContext(ctx, getLicenses) + if err != nil { + return nil, err + } + defer rows.Close() + var items []License + for rows.Next() { + var i License + if err := rows.Scan( + &i.ID, + &i.UploadedAt, + &i.JWT, + &i.Exp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertLicense = `-- name: InsertLicense :one INSERT INTO licenses ( diff --git a/coderd/database/queries/licenses.sql b/coderd/database/queries/licenses.sql index 3add1b59e02c5..b00f1c5e7f211 100644 --- a/coderd/database/queries/licenses.sql +++ b/coderd/database/queries/licenses.sql @@ -7,3 +7,9 @@ INSERT INTO ) VALUES ($1, $2, $3) RETURNING *; + + +-- name: GetLicenses :many +SELECT * +FROM licenses +ORDER BY (id); diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 56af2eb68c0b1..67a0219ec149e 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -50,7 +50,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { if daemons == nil { daemons = []database.ProvisionerDaemon{} } - daemons, err = AuthorizeFilter(api, r, rbac.ActionRead, daemons) + daemons, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, daemons) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching provisioner daemons.", diff --git a/coderd/templates.go b/coderd/templates.go index 217bab3f36330..acf826e12f729 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -273,7 +273,7 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request) } // Filter templates based on rbac permissions - templates, err = AuthorizeFilter(api, r, rbac.ActionRead, templates) + templates, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, templates) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching templates.", diff --git a/coderd/users.go b/coderd/users.go index d3460631a951e..eaef906f15db8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -158,7 +158,7 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) { return } - users, err = AuthorizeFilter(api, r, rbac.ActionRead, users) + users, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, users) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching users.", @@ -503,7 +503,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } // Only include ones we can read from RBAC. - memberships, err = AuthorizeFilter(api, r, rbac.ActionRead, memberships) + memberships, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, memberships) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching memberships.", @@ -631,7 +631,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) { } // Only return orgs the user can read. - organizations, err = AuthorizeFilter(api, r, rbac.ActionRead, organizations) + organizations, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, organizations) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching organizations.", diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3b0e7a486a482..a58a9e198293c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -145,7 +145,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { } // Only return workspaces the user can read - workspaces, err = AuthorizeFilter(api, r, rbac.ActionRead, workspaces) + workspaces, err = AuthorizeFilter(api.httpAuth, r, rbac.ActionRead, workspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspaces.", diff --git a/codersdk/licenses.go b/codersdk/licenses.go index c97c5885df891..c00a3e21a51e3 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -35,3 +35,18 @@ func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License, d.UseNumber() return l, d.Decode(&l) } + +func (c *Client) GetLicenses(ctx context.Context) ([]License, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/licenses", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var licenses []License + d := json.NewDecoder(res.Body) + d.UseNumber() + return licenses, d.Decode(&licenses) +} diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 630c1ffe18619..aa86e452f2c40 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -1,10 +1,15 @@ package coderd import ( + "bytes" "context" "crypto/ed25519" + "database/sql" _ "embed" + "encoding/base64" + "encoding/json" "net/http" + "strings" "time" "golang.org/x/xerrors" @@ -119,6 +124,7 @@ func newLicenseAPI( r := chi.NewRouter() a := &licenseAPI{router: r, logger: l, database: db, pubsub: ps, auth: auth} r.Post("/", a.postLicense) + r.Get("/", a.licenses) return a } @@ -192,3 +198,70 @@ func convertLicense(dl database.License, c jwt.MapClaims) codersdk.License { Claims: c, } } + +func (a *licenseAPI) licenses(rw http.ResponseWriter, r *http.Request) { + licenses, err := a.database.GetLicenses(r.Context()) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusOK, []codersdk.License{}) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching licenses.", + Detail: err.Error(), + }) + return + } + + licenses, err = coderd.AuthorizeFilter(a.auth, r, rbac.ActionRead, licenses) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching licenses.", + Detail: err.Error(), + }) + return + } + sdkLicenses, err := convertLicenses(licenses) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error parsing licenses.", + Detail: err.Error(), + }) + return + } + httpapi.Write(rw, http.StatusOK, sdkLicenses) +} + +func convertLicenses(licenses []database.License) ([]codersdk.License, error) { + var out []codersdk.License + for _, l := range licenses { + c, err := decodeClaims(l) + if err != nil { + return nil, err + } + out = append(out, convertLicense(l, c)) + } + return out, nil +} + +// decodeClaims decodes the JWT claims from the stored JWT. Note here we do not validate the JWT +// and just return the claims verbatim. We want to include all licenses on the GET response, even +// if they are expired, or signed by a key this version of Coder no longer considers valid. +// +// Also, we do not return the whole JWT itself because a signed JWT is a bearer token and we +// want to limit the chance of it being accidentally leaked. +func decodeClaims(l database.License) (jwt.MapClaims, error) { + parts := strings.Split(l.JWT, ".") + if len(parts) != 3 { + return nil, xerrors.Errorf("Unable to parse license %d as JWT", l.ID) + } + cb, err := base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, xerrors.Errorf("Unable to decode license %d claims: %w", l.ID, err) + } + c := make(jwt.MapClaims) + d := json.NewDecoder(bytes.NewBuffer(cb)) + d.UseNumber() + err = d.Decode(&c) + return c, err +} diff --git a/enterprise/coderd/licenses_internal_test.go b/enterprise/coderd/licenses_internal_test.go index e1ac0d7af5ad3..8a05aa97aee8f 100644 --- a/enterprise/coderd/licenses_internal_test.go +++ b/enterprise/coderd/licenses_internal_test.go @@ -142,6 +142,76 @@ func TestPostLicense(t *testing.T) { }) } +// these tests patch the map of license keys, so cannot be run in parallel +// nolint:paralleltest +func TestGetLicense(t *testing.T) { + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + keyID := "testing" + oldKeys := keys + defer func() { + t.Log("restoring keys") + keys = oldKeys + }() + keys = map[string]ed25519.PublicKey{keyID: pubKey} + + t.Run("GET", func(t *testing.T) { + client := coderdtest.New(t, &coderdtest.Options{APIBuilder: NewEnterprise}) + _ = coderdtest.CreateFirstUser(t, client) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + claims := &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "test@coder.test", + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + }, + LicenseExpires: jwt.NewNumericDate(time.Now().Add(time.Hour)), + AccountType: AccountTypeSalesforce, + AccountID: "testing", + Version: CurrentVersion, + Features: Features{ + UserLimit: 0, + AuditLog: 1, + }, + } + lic, err := makeLicense(claims, privKey, keyID) + require.NoError(t, err) + _, err = client.AddLicense(ctx, codersdk.AddLicenseRequest{ + License: lic, + }) + require.NoError(t, err) + + // 2nd license + claims.AccountID = "testing2" + claims.Features.UserLimit = 200 + lic2, err := makeLicense(claims, privKey, keyID) + require.NoError(t, err) + _, err = client.AddLicense(ctx, codersdk.AddLicenseRequest{ + License: lic2, + }) + require.NoError(t, err) + + licenses, err := client.GetLicenses(ctx) + require.NoError(t, err) + require.Len(t, licenses, 2) + assert.Equal(t, int32(1), licenses[0].ID) + assert.Equal(t, "testing", licenses[0].Claims["account_id"]) + assert.Equal(t, map[string]interface{}{ + codersdk.FeatureUserLimit: json.Number("0"), + codersdk.FeatureAuditLog: json.Number("1"), + }, licenses[0].Claims["features"]) + assert.Equal(t, int32(2), licenses[1].ID) + assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) + assert.Equal(t, map[string]interface{}{ + codersdk.FeatureUserLimit: json.Number("200"), + codersdk.FeatureAuditLog: json.Number("1"), + }, licenses[1].Claims["features"]) + }) +} + func makeLicense(c *Claims, privateKey ed25519.PrivateKey, keyID string) (string, error) { tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) tok.Header[HeaderKeyID] = keyID From 5663de44b9f7936a08e177fe7cbac245b1608896 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 15:17:53 -0700 Subject: [PATCH 2/3] SDK GetLicenses -> Licenses Signed-off-by: Spike Curtis --- codersdk/licenses.go | 2 +- enterprise/coderd/licenses_internal_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codersdk/licenses.go b/codersdk/licenses.go index c00a3e21a51e3..bdfcad4434a09 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -36,7 +36,7 @@ func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License, return l, d.Decode(&l) } -func (c *Client) GetLicenses(ctx context.Context) ([]License, error) { +func (c *Client) Licenses(ctx context.Context) ([]License, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/licenses", nil) if err != nil { return nil, err diff --git a/enterprise/coderd/licenses_internal_test.go b/enterprise/coderd/licenses_internal_test.go index 8a05aa97aee8f..82990dd3582a6 100644 --- a/enterprise/coderd/licenses_internal_test.go +++ b/enterprise/coderd/licenses_internal_test.go @@ -194,7 +194,7 @@ func TestGetLicense(t *testing.T) { }) require.NoError(t, err) - licenses, err := client.GetLicenses(ctx) + licenses, err := client.Licenses(ctx) require.NoError(t, err) require.Len(t, licenses, 2) assert.Equal(t, int32(1), licenses[0].ID) From d1194abcab25874adc5fa281327cfb4b9f081cdd Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 16:50:24 -0700 Subject: [PATCH 3/3] Fix dump.sql newlines Signed-off-by: Spike Curtis --- coderd/database/dump.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a262c428d700e..8c3e08a6b1995 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -559,3 +559,4 @@ ALTER TABLE ONLY workspaces ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; +