mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
POST license API endpoint (#3570)
* POST license API Signed-off-by: Spike Curtis <spike@coder.com> * Support interface{} types in generated Typescript Signed-off-by: Spike Curtis <spike@coder.com> * Disable linting on empty interface any Signed-off-by: Spike Curtis <spike@coder.com> * Code review updates Signed-off-by: Spike Curtis <spike@coder.com> * Enforce unique licenses Signed-off-by: Spike Curtis <spike@coder.com> * Renames from code review Signed-off-by: Spike Curtis <spike@coder.com> * Code review renames and comments Signed-off-by: Spike Curtis <spike@coder.com> Signed-off-by: Spike Curtis <spike@coder.com>
This commit is contained in:
30
enterprise/coderd/coderd.go
Normal file
30
enterprise/coderd/coderd.go
Normal file
@ -0,0 +1,30 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
)
|
||||
|
||||
func NewEnterprise(options *coderd.Options) *coderd.API {
|
||||
var eOpts = *options
|
||||
if eOpts.Authorizer == nil {
|
||||
var err error
|
||||
eOpts.Authorizer, err = rbac.NewAuthorizer()
|
||||
if err != nil {
|
||||
// This should never happen, as the unit tests would fail if the
|
||||
// default built in authorizer failed.
|
||||
panic(xerrors.Errorf("rego authorize panic: %w", err))
|
||||
}
|
||||
}
|
||||
eOpts.LicenseHandler = newLicenseAPI(
|
||||
eOpts.Logger,
|
||||
eOpts.Database,
|
||||
eOpts.Pubsub,
|
||||
&coderd.HTTPAuthorizer{
|
||||
Authorizer: eOpts.Authorizer,
|
||||
Logger: eOpts.Logger,
|
||||
}).handler()
|
||||
return coderd.New(&eOpts)
|
||||
}
|
1
enterprise/coderd/keys/2022-08-12
Normal file
1
enterprise/coderd/keys/2022-08-12
Normal file
@ -0,0 +1 @@
|
||||
gjޝ",!<21><> <09><>6v<36><76><1B><>h/<2F><>cmί/
|
194
enterprise/coderd/licenses.go
Normal file
194
enterprise/coderd/licenses.go
Normal file
@ -0,0 +1,194 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentVersion = 3
|
||||
HeaderKeyID = "kid"
|
||||
AccountTypeSalesforce = "salesforce"
|
||||
VersionClaim = "version"
|
||||
PubSubEventLicenses = "licenses"
|
||||
)
|
||||
|
||||
var ValidMethods = []string{"EdDSA"}
|
||||
|
||||
// key20220812 is the Coder license public key with id 2022-08-12 used to validate licenses signed
|
||||
// by our signing infrastructure
|
||||
//go:embed keys/2022-08-12
|
||||
var key20220812 []byte
|
||||
|
||||
var keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}
|
||||
|
||||
type Features struct {
|
||||
UserLimit int64 `json:"user_limit"`
|
||||
AuditLog int64 `json:"audit_log"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
// LicenseExpires is the end of the legit license term, and the start of the grace period, if
|
||||
// there is one. The standard JWT claim "exp" (ExpiresAt in jwt.RegisteredClaims, above) is
|
||||
// the end of the grace period (identical to LicenseExpires if there is no grace period).
|
||||
// The reason we use the standard claim for the end of the grace period is that we want JWT
|
||||
// processing libraries to consider the token "valid" until then.
|
||||
LicenseExpires *jwt.NumericDate `json:"license_expires,omitempty"`
|
||||
AccountType string `json:"account_type,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Version uint64 `json:"version"`
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidVersion = xerrors.New("license must be version 3")
|
||||
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
|
||||
)
|
||||
|
||||
// parseLicense parses the license and returns the claims. If the license's signature is invalid or
|
||||
// is not parsable, an error is returned.
|
||||
func parseLicense(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
|
||||
tok, err := jwt.Parse(
|
||||
l,
|
||||
keyFunc(keys),
|
||||
jwt.WithValidMethods(ValidMethods),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := tok.Claims.(jwt.MapClaims); ok && tok.Valid {
|
||||
version, ok := claims[VersionClaim].(float64)
|
||||
if !ok {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
if int64(version) != CurrentVersion {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
return nil, xerrors.New("unable to parse Claims")
|
||||
}
|
||||
|
||||
func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, error) {
|
||||
return func(j *jwt.Token) (interface{}, error) {
|
||||
keyID, ok := j.Header[HeaderKeyID].(string)
|
||||
if !ok {
|
||||
return nil, ErrMissingKeyID
|
||||
}
|
||||
k, ok := keys[keyID]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("no key with ID %s", keyID)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
|
||||
// licenseAPI handles enterprise licenses, and attaches to the main coderd.API via the
|
||||
// LicenseHandler option, so that it serves all routes under /api/v2/licenses
|
||||
type licenseAPI struct {
|
||||
router chi.Router
|
||||
logger slog.Logger
|
||||
database database.Store
|
||||
pubsub database.Pubsub
|
||||
auth *coderd.HTTPAuthorizer
|
||||
}
|
||||
|
||||
func newLicenseAPI(
|
||||
l slog.Logger,
|
||||
db database.Store,
|
||||
ps database.Pubsub,
|
||||
auth *coderd.HTTPAuthorizer,
|
||||
) *licenseAPI {
|
||||
r := chi.NewRouter()
|
||||
a := &licenseAPI{router: r, logger: l, database: db, pubsub: ps, auth: auth}
|
||||
r.Post("/", a.postLicense)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *licenseAPI) handler() http.Handler {
|
||||
return a.router
|
||||
}
|
||||
|
||||
// postLicense adds a new Enterprise license to the cluster. We allow multiple different licenses
|
||||
// in the cluster at one time for several reasons:
|
||||
//
|
||||
// 1. Upgrades --- if the license format changes from one version of Coder to the next, during a
|
||||
// rolling update you will have different Coder servers that need different licenses to function.
|
||||
// 2. Avoid abrupt feature breakage --- when an admin uploads a new license with different features
|
||||
// we generally don't want the old features to immediately break without warning. With a grace
|
||||
// period on the license, features will continue to work from the old license until its grace
|
||||
// period, then the users will get a warning allowing them to gracefully stop using the feature.
|
||||
func (a *licenseAPI) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
if !a.auth.Authorize(r, rbac.ActionCreate, rbac.ResourceLicense) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var addLicense codersdk.AddLicenseRequest
|
||||
if !httpapi.Read(rw, r, &addLicense) {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := parseLicense(addLicense.License, keys)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok {
|
||||
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: "exp claim missing or not parsable",
|
||||
})
|
||||
return
|
||||
}
|
||||
expTime := time.Unix(int64(exp), 0)
|
||||
|
||||
dl, err := a.database.InsertLicense(r.Context(), database.InsertLicenseParams{
|
||||
UploadedAt: database.Now(),
|
||||
JWT: addLicense.License,
|
||||
Exp: expTime,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Unable to add license to database",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = a.pubsub.Publish(PubSubEventLicenses, []byte("add"))
|
||||
if err != nil {
|
||||
a.logger.Error(context.Background(), "failed to publish license add", slog.Error(err))
|
||||
// don't fail the HTTP request, since we did write it successfully to the database
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusCreated, convertLicense(dl, claims))
|
||||
}
|
||||
|
||||
func convertLicense(dl database.License, c jwt.MapClaims) codersdk.License {
|
||||
return codersdk.License{
|
||||
ID: dl.ID,
|
||||
UploadedAt: dl.UploadedAt,
|
||||
Claims: c,
|
||||
}
|
||||
}
|
153
enterprise/coderd/licenses_internal_test.go
Normal file
153
enterprise/coderd/licenses_internal_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// these tests patch the map of license keys, so cannot be run in parallel
|
||||
// nolint:paralleltest
|
||||
func TestPostLicense(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("POST", 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)
|
||||
|
||||
respLic, err := client.AddLicense(ctx, codersdk.AddLicenseRequest{
|
||||
License: lic,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, respLic.ID, int32(0))
|
||||
// just a couple spot checks for sanity
|
||||
assert.Equal(t, claims.AccountID, respLic.Claims["account_id"])
|
||||
features, ok := respLic.Claims["features"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, json.Number("1"), features[codersdk.FeatureAuditLog])
|
||||
})
|
||||
|
||||
t.Run("POST_unathorized", func(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{APIBuilder: NewEnterprise})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
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,
|
||||
})
|
||||
errResp := &codersdk.Error{}
|
||||
if xerrors.As(err, &errResp) {
|
||||
assert.Equal(t, 401, errResp.StatusCode())
|
||||
} else {
|
||||
t.Error("expected to get error status 401")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST_corrupted", func(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{APIBuilder: NewEnterprise})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
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: "h" + lic,
|
||||
})
|
||||
errResp := &codersdk.Error{}
|
||||
if xerrors.As(err, &errResp) {
|
||||
assert.Equal(t, 400, errResp.StatusCode())
|
||||
} else {
|
||||
t.Error("expected to get error status 400")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func makeLicense(c *Claims, privateKey ed25519.PrivateKey, keyID string) (string, error) {
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
|
||||
tok.Header[HeaderKeyID] = keyID
|
||||
signedTok, err := tok.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("sign license: %w", err)
|
||||
}
|
||||
return signedTok, nil
|
||||
}
|
Reference in New Issue
Block a user