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:
Spike Curtis 2022-08-22 15:02:50 -07:00 committed by GitHub
parent 85acfdf0dc
commit b101a6f3f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 666 additions and 50 deletions

View File

@ -21,7 +21,7 @@ import (
// New creates a CLI instance with a configuration pointed to a
// temporary testing directory.
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
cmd := cli.Root()
cmd := cli.Root(cli.AGPL())
dir := t.TempDir()
root := config.Root(dir)
cmd.SetArgs(append([]string{"--global-config", dir}, args...))

View File

@ -20,6 +20,7 @@ import (
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)
@ -58,7 +59,42 @@ func init() {
cobra.AddTemplateFuncs(templateFunctions)
}
func Root() *cobra.Command {
func Core() []*cobra.Command {
return []*cobra.Command{
configSSH(),
create(),
deleteWorkspace(),
dotfiles(),
gitssh(),
list(),
login(),
logout(),
parameters(),
portForward(),
publickey(),
resetPassword(),
schedules(),
show(),
ssh(),
start(),
state(),
stop(),
templates(),
update(),
users(),
versionCmd(),
wireguardPortForward(),
workspaceAgent(),
features(),
}
}
func AGPL() []*cobra.Command {
all := append(Core(), Server(coderd.New))
return all
}
func Root(subcommands []*cobra.Command) *cobra.Command {
cmd := &cobra.Command{
Use: "coder",
SilenceErrors: true,
@ -109,34 +145,7 @@ func Root() *cobra.Command {
),
}
cmd.AddCommand(
configSSH(),
create(),
deleteWorkspace(),
dotfiles(),
gitssh(),
list(),
login(),
logout(),
parameters(),
portForward(),
publickey(),
resetPassword(),
schedules(),
server(),
show(),
ssh(),
start(),
state(),
stop(),
templates(),
update(),
users(),
versionCmd(),
wireguardPortForward(),
workspaceAgent(),
features(),
)
cmd.AddCommand(subcommands...)
cmd.SetUsageTemplate(usageTemplate())

View File

@ -68,7 +68,7 @@ import (
)
// nolint:gocyclo
func server() *cobra.Command {
func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
var (
accessURL string
address string
@ -434,7 +434,7 @@ func server() *cobra.Command {
), promAddress, "prometheus")()
}
coderAPI := coderd.New(options)
coderAPI := newAPI(options)
defer coderAPI.Close()
client := codersdk.New(localURL)
@ -886,16 +886,16 @@ func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
// nolint: revive
func printLogo(cmd *cobra.Command, spooky bool) {
if spooky {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), `
_, _ = fmt.Fprintf(cmd.OutOrStdout(), `
`)
return
}

View File

@ -15,7 +15,7 @@ import (
func main() {
rand.Seed(time.Now().UnixMicro())
cmd, err := cli.Root().ExecuteC()
cmd, err := cli.Root(cli.AGPL()).ExecuteC()
if err != nil {
if errors.Is(err, cliui.Canceled) {
os.Exit(1)

View File

@ -27,6 +27,11 @@ func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Act
return objects, nil
}
type HTTPAuthorizer struct {
Authorizer rbac.Authorizer
Logger slog.Logger
}
// Authorize will return false if the user is not authorized to do the action.
// This function will log appropriately, but the caller must return an
// error to the api client.
@ -37,14 +42,26 @@ func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Act
// return
// }
func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
return api.httpAuth.Authorize(r, action, object)
}
// Authorize will return false if the user is not authorized to do the action.
// This function will log appropriately, but the caller must return an
// error to the api client.
// Eg:
// if !h.Authorize(...) {
// httpapi.Forbidden(rw)
// return
// }
func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
roles := httpmw.AuthorizationUserRoles(r)
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
if err != nil {
// Log the errors for debugging
internalError := new(rbac.UnauthorizedError)
logger := api.Logger
logger := h.Logger
if xerrors.As(err, internalError) {
logger = api.Logger.With(slog.F("internal", internalError.Internal()))
logger = h.Logger.With(slog.F("internal", internalError.Internal()))
}
// Log information for debugging. This will be very helpful
// in the early days

View File

@ -66,6 +66,7 @@ type Options struct {
Telemetry telemetry.Reporter
TURNServer *turnconn.Server
TracerProvider *sdktrace.TracerProvider
LicenseHandler http.Handler
}
// New constructs a Coder API handler.
@ -92,6 +93,9 @@ func New(options *Options) *API {
if options.PrometheusRegistry == nil {
options.PrometheusRegistry = prometheus.NewRegistry()
}
if options.LicenseHandler == nil {
options.LicenseHandler = licenses()
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
@ -107,6 +111,10 @@ func New(options *Options) *API {
Options: options,
Handler: r,
siteHandler: site.Handler(site.FS(), binFS),
httpAuth: &HTTPAuthorizer{
Authorizer: options.Authorizer,
Logger: options.Logger,
},
}
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
oauthConfigs := &httpmw.OAuth2Configs{
@ -395,6 +403,10 @@ func New(options *Options) *API {
r.Use(apiKeyMiddleware)
r.Get("/", entitlements)
})
r.Route("/licenses", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Mount("/", options.LicenseHandler)
})
})
r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP)
@ -409,6 +421,7 @@ type API struct {
websocketWaitMutex sync.Mutex
websocketWaitGroup sync.WaitGroup
workspaceAgentCache *wsconncache.Cache
httpAuth *HTTPAuthorizer
}
// Close waits for all WebSocket connections to drain before returning.

View File

@ -73,6 +73,7 @@ type Options struct {
// IncludeProvisionerD when true means to start an in-memory provisionerD
IncludeProvisionerD bool
APIBuilder func(*coderd.Options) *coderd.API
}
// New constructs a codersdk client connected to an in-memory API instance.
@ -122,6 +123,9 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
close(options.AutobuildStats)
})
}
if options.APIBuilder == nil {
options.APIBuilder = coderd.New
}
// This can be hotswapped for a live database instance.
db := databasefake.New()
@ -177,7 +181,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
})
// We set the handler after server creation for the access URL.
coderAPI := coderd.New(&coderd.Options{
coderAPI := options.APIBuilder(&coderd.Options{
AgentConnectionUpdateFrequency: 150 * time.Millisecond,
// Force a long disconnection timeout to ensure
// agents are not marked as disconnected during slow tests.

View File

@ -42,6 +42,7 @@ func New() database.Store {
workspaceBuilds: make([]database.WorkspaceBuild, 0),
workspaceApps: make([]database.WorkspaceApp, 0),
workspaces: make([]database.Workspace, 0),
licenses: make([]database.License, 0),
},
}
}
@ -92,8 +93,10 @@ type data struct {
workspaceBuilds []database.WorkspaceBuild
workspaceApps []database.WorkspaceApp
workspaces []database.Workspace
licenses []database.License
deploymentID string
deploymentID string
lastLicenseID int32
}
// InTx doesn't rollback data properly for in-memory yet.
@ -2277,6 +2280,22 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) {
return q.deploymentID, nil
}
func (q *fakeQuerier) InsertLicense(
_ context.Context, arg database.InsertLicenseParams) (database.License, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
l := database.License{
ID: q.lastLicenseID + 1,
UploadedAt: arg.UploadedAt,
JWT: arg.JWT,
Exp: arg.Exp,
}
q.lastLicenseID = l.ID
q.licenses = append(q.licenses, l)
return l, nil
}
func (q *fakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

View File

@ -133,8 +133,9 @@ CREATE TABLE gitsshkeys (
CREATE TABLE licenses (
id integer NOT NULL,
license jsonb NOT NULL,
created_at timestamp with time zone NOT NULL
uploaded_at timestamp with time zone NOT NULL,
jwt text NOT NULL,
exp timestamp with time zone NOT NULL
);
CREATE SEQUENCE licenses_id_seq
@ -378,6 +379,9 @@ ALTER TABLE ONLY files
ALTER TABLE ONLY gitsshkeys
ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY licenses
ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
ALTER TABLE ONLY licenses
ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);

View File

@ -0,0 +1,7 @@
-- Valid licenses don't fit into old format, so delete all data
DELETE FROM licenses;
ALTER TABLE licenses DROP COLUMN jwt;
ALTER TABLE licenses RENAME COLUMN uploaded_at to created_at;
ALTER TABLE licenses ADD COLUMN license jsonb NOT NULL;
ALTER TABLE licenses DROP COLUMN exp;

View File

@ -0,0 +1,10 @@
-- No valid licenses should exist, but to be sure, drop all rows
DELETE FROM licenses;
ALTER TABLE licenses DROP COLUMN license;
ALTER TABLE licenses RENAME COLUMN created_at to uploaded_at;
ALTER TABLE licenses ADD COLUMN jwt text NOT NULL;
-- prevent adding the same license more than once
ALTER TABLE licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
ALTER TABLE licenses ADD COLUMN exp timestamp with time zone NOT NULL;
COMMENT ON COLUMN licenses.exp IS 'exp tracks the claim of the same name in the JWT, and we include it here so that we can easily query for licenses that have not yet expired.';

View File

@ -357,9 +357,10 @@ type GitSSHKey struct {
}
type License struct {
ID int32 `db:"id" json:"id"`
License json.RawMessage `db:"license" json:"license"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ID int32 `db:"id" json:"id"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
JWT string `db:"jwt" json:"jwt"`
Exp time.Time `db:"exp" json:"exp"`
}
type Organization struct {

View File

@ -99,6 +99,7 @@ type querier interface {
InsertDeploymentID(ctx context.Context, value string) error
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error)

View File

@ -475,6 +475,35 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar
return err
}
const insertLicense = `-- name: InsertLicense :one
INSERT INTO
licenses (
uploaded_at,
jwt,
exp
)
VALUES
($1, $2, $3) RETURNING id, uploaded_at, jwt, exp
`
type InsertLicenseParams struct {
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
JWT string `db:"jwt" json:"jwt"`
Exp time.Time `db:"exp" json:"exp"`
}
func (q *sqlQuerier) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) {
row := q.db.QueryRowContext(ctx, insertLicense, arg.UploadedAt, arg.JWT, arg.Exp)
var i License
err := row.Scan(
&i.ID,
&i.UploadedAt,
&i.JWT,
&i.Exp,
)
return i, err
}
const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many
SELECT
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"

View File

@ -0,0 +1,9 @@
-- name: InsertLicense :one
INSERT INTO
licenses (
uploaded_at,
jwt,
exp
)
VALUES
($1, $2, $3) RETURNING *;

View File

@ -35,3 +35,4 @@ rename:
rbac_roles: RBACRoles
ip_address: IPAddress
wireguard_node_ipv6: WireguardNodeIPv6
jwt: JWT

24
coderd/licenses.go Normal file
View File

@ -0,0 +1,24 @@
package coderd
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
func licenses() http.Handler {
r := chi.NewRouter()
r.NotFound(unsupported)
return r
}
func unsupported(rw http.ResponseWriter, _ *http.Request) {
httpapi.Write(rw, http.StatusNotFound, codersdk.Response{
Message: "Unsupported",
Detail: "These endpoints are not supported in AGPL-licensed Coder",
Validations: nil,
})
}

View File

@ -115,6 +115,15 @@ var (
ResourceWildcard = Object{
Type: WildcardSymbol,
}
// ResourceLicense is the license in the 'licenses' table.
// ResourceLicense is site wide.
// create/delete = add or remove license from site.
// read = view license claims
// update = not applicable; licenses are immutable
ResourceLicense = Object{
Type: "license",
}
)
// Object is used to create objects for authz checks when you have none in

37
codersdk/licenses.go Normal file
View File

@ -0,0 +1,37 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"time"
)
type AddLicenseRequest struct {
License string `json:"license" validate:"required"`
}
type License struct {
ID int32 `json:"id"`
UploadedAt time.Time `json:"uploaded_at"`
// Claims are the JWT claims asserted by the license. Here we use
// a generic string map to ensure that all data from the server is
// parsed verbatim, not just the fields this version of Coder
// understands.
Claims map[string]interface{} `json:"claims"`
}
func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/licenses", r)
if err != nil {
return License{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return License{}, readBodyAsError(res)
}
var l License
d := json.NewDecoder(res.Body)
d.UseNumber()
return l, d.Decode(&l)
}

13
enterprise/cli/root.go Normal file
View File

@ -0,0 +1,13 @@
package cli
import (
"github.com/spf13/cobra"
agpl "github.com/coder/coder/cli"
"github.com/coder/coder/enterprise/coderd"
)
func EnterpriseSubcommands() []*cobra.Command {
all := append(agpl.Core(), agpl.Server(coderd.NewEnterprise))
return all
}

View File

@ -10,12 +10,13 @@ import (
"github.com/coder/coder/cli"
"github.com/coder/coder/cli/cliui"
entcli "github.com/coder/coder/enterprise/cli"
)
func main() {
rand.Seed(time.Now().UnixMicro())
cmd, err := cli.Root().ExecuteC()
cmd, err := cli.Root(entcli.EnterpriseSubcommands()).ExecuteC()
if err != nil {
if errors.Is(err, cliui.Canceled) {
os.Exit(1)

View 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)
}

View File

@ -0,0 +1 @@
gj逎",!本 纺6v<36>嚃h/埲cm委/

View 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,
}
}

View 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
}

1
go.mod
View File

@ -144,6 +144,7 @@ require (
)
require (
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
)

2
go.sum
View File

@ -794,6 +794,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.15.2 h1:vU+M05vs6jWHKDdmE1Ecwj0BznygFc4QsdRe2E/L7kc=
github.com/golang-migrate/migrate/v4 v4.15.2/go.mod h1:f2toGLkYqD3JH+Todi4aZ2ZdbeUNx4sIwiOK96rE9Lw=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=

View File

@ -382,8 +382,14 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
return TypescriptType{}, xerrors.Errorf("map key: %w", err)
}
aboveTypeLine := keyType.AboveTypeLine
if aboveTypeLine != "" && valueType.AboveTypeLine != "" {
aboveTypeLine = aboveTypeLine + "\n"
}
aboveTypeLine = aboveTypeLine + valueType.AboveTypeLine
return TypescriptType{
ValueType: fmt.Sprintf("Record<%s, %s>", keyType.ValueType, valueType.ValueType),
ValueType: fmt.Sprintf("Record<%s, %s>", keyType.ValueType, valueType.ValueType),
AboveTypeLine: aboveTypeLine,
}, nil
case *types.Slice, *types.Array:
// Slice/Arrays are pretty much the same.
@ -458,6 +464,14 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
}
resp.Optional = true
return resp, nil
case *types.Interface:
// only handle the empty interface for now
intf := ty
if intf.Empty() {
return TypescriptType{ValueType: "any",
AboveTypeLine: indentedComment("eslint-disable-next-line")}, nil
}
return TypescriptType{}, xerrors.New("only empty interface types are supported")
}
// These are all the other types we need to support.

View File

@ -18,6 +18,11 @@ export interface AWSInstanceIdentityToken {
readonly document: string
}
// From codersdk/licenses.go
export interface AddLicenseRequest {
readonly license: string
}
// From codersdk/gitsshkey.go
export interface AgentGitSSHKey {
readonly public_key: string
@ -168,6 +173,14 @@ export interface GoogleInstanceIdentityToken {
readonly json_web_token: string
}
// From codersdk/licenses.go
export interface License {
readonly id: number
readonly uploaded_at: string
// eslint-disable-next-line
readonly claims: Record<string, any>
}
// From codersdk/users.go
export interface LoginWithPasswordRequest {
readonly email: string