mirror of https://github.com/coder/coder.git
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:
parent
85acfdf0dc
commit
b101a6f3f4
|
@ -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...))
|
||||
|
|
67
cli/root.go
67
cli/root.go
|
@ -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())
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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.';
|
||||
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
-- name: InsertLicense :one
|
||||
INSERT INTO
|
||||
licenses (
|
||||
uploaded_at,
|
||||
jwt,
|
||||
exp
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3) RETURNING *;
|
|
@ -35,3 +35,4 @@ rename:
|
|||
rbac_roles: RBACRoles
|
||||
ip_address: IPAddress
|
||||
wireguard_node_ipv6: WireguardNodeIPv6
|
||||
jwt: JWT
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
gj逎",!本 纺6v<36>嚃h/埲cm委/
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
1
go.mod
|
@ -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
2
go.sum
|
@ -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=
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue