From b9d5915b7beef82b906ffe0bd2a480475dec0e90 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 17 Aug 2022 09:03:37 -0700 Subject: [PATCH 1/7] POST license API Signed-off-by: Spike Curtis --- cli/clitest/clitest.go | 2 +- cli/features.go | 3 +- cli/root.go | 67 ++++--- cli/server.go | 17 +- cmd/coder/main.go | 2 +- coderd/authorize.go | 23 ++- coderd/coderd.go | 13 ++ coderd/coderdtest/coderdtest.go | 6 +- coderd/database/databasefake/databasefake.go | 21 ++- coderd/database/dump.sql | 5 +- .../migrations/000035_jwt_licenses.down.sql | 7 + .../migrations/000035_jwt_licenses.up.sql | 7 + coderd/database/models.go | 7 +- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 29 +++ coderd/database/queries/licenses.sql | 9 + coderd/features_internal_test.go | 3 +- coderd/licenses.go | 24 +++ coderd/rbac/object.go | 9 + codersdk/licenses.go | 37 ++++ enterprise/cli/root.go | 13 ++ enterprise/cmd/coder/main.go | 3 +- enterprise/coderd/coderd.go | 30 +++ enterprise/coderd/keys/2022-08-12 | 1 + enterprise/coderd/licenses.go | 175 ++++++++++++++++++ enterprise/coderd/licenses_internal_test.go | 153 +++++++++++++++ go.mod | 1 + go.sum | 2 + site/src/api/typesGenerated.ts | 5 + 29 files changed, 623 insertions(+), 52 deletions(-) create mode 100644 coderd/database/migrations/000035_jwt_licenses.down.sql create mode 100644 coderd/database/migrations/000035_jwt_licenses.up.sql create mode 100644 coderd/database/queries/licenses.sql create mode 100644 coderd/licenses.go create mode 100644 codersdk/licenses.go create mode 100644 enterprise/cli/root.go create mode 100644 enterprise/coderd/coderd.go create mode 100644 enterprise/coderd/keys/2022-08-12 create mode 100644 enterprise/coderd/licenses.go create mode 100644 enterprise/coderd/licenses_internal_test.go diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 53cf6b28a9016..6ce6d74b20900 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -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.AGPLSubcommands()) dir := t.TempDir() root := config.Root(dir) cmd.SetArgs(append([]string{"--global-config", dir}, args...)) diff --git a/cli/features.go b/cli/features.go index 307404d5c83c6..60f61c673b304 100644 --- a/cli/features.go +++ b/cli/features.go @@ -5,12 +5,11 @@ import ( "fmt" "strings" - "github.com/coder/coder/cli/cliui" "github.com/jedib0t/go-pretty/v6/table" - "github.com/spf13/cobra" "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) diff --git a/cli/root.go b/cli/root.go index 74a37ac4b6d8b..62c2ab0a27986 100644 --- a/cli/root.go +++ b/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 CoreSubcommands() []*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 AGPLSubcommands() []*cobra.Command { + all := append(CoreSubcommands(), 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()) diff --git a/cli/server.go b/cli/server.go index 86016d9c9bd7a..40e82c8d40d8b 100644 --- a/cli/server.go +++ b/cli/server.go @@ -67,8 +67,10 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +type CoderServerBuilder func(*coderd.Options) *coderd.API + // nolint:gocyclo -func server() *cobra.Command { +func Server(builder CoderServerBuilder) *cobra.Command { var ( accessURL string address string @@ -284,6 +286,7 @@ func server() *cobra.Command { TURNServer: turnServer, TracerProvider: tracerProvider, Telemetry: telemetry.NewNoop(), + //LicenseHandler: coderd.Licenses(), } if oauth2GithubClientSecret != "" { @@ -421,7 +424,7 @@ func server() *cobra.Command { ), promAddress, "prometheus")() } - coderAPI := coderd.New(options) + coderAPI := builder(options) defer coderAPI.Close() client := codersdk.New(localURL) @@ -881,16 +884,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 } diff --git a/cmd/coder/main.go b/cmd/coder/main.go index f90464bd2e625..a8b89abca9b43 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -15,7 +15,7 @@ import ( func main() { rand.Seed(time.Now().UnixMicro()) - cmd, err := cli.Root().ExecuteC() + cmd, err := cli.Root(cli.AGPLSubcommands()).ExecuteC() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) diff --git a/coderd/authorize.go b/coderd/authorize.go index 855348aaf6184..7516a5dd21d80 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -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. @@ -36,14 +41,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 diff --git a/coderd/coderd.go b/coderd/coderd.go index cf29aa986fc22..7623d562e888d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 90f9482999862..d8d5cc97ea76a 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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. diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6623d338af7c4..8d96126840119 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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), }, } } @@ -91,8 +92,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. @@ -2259,3 +2262,19 @@ 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 +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 87853f23fe16e..cf6868496ac17 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -137,8 +137,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 diff --git a/coderd/database/migrations/000035_jwt_licenses.down.sql b/coderd/database/migrations/000035_jwt_licenses.down.sql new file mode 100644 index 0000000000000..274dabae7a457 --- /dev/null +++ b/coderd/database/migrations/000035_jwt_licenses.down.sql @@ -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; + diff --git a/coderd/database/migrations/000035_jwt_licenses.up.sql b/coderd/database/migrations/000035_jwt_licenses.up.sql new file mode 100644 index 0000000000000..aa5146acb579e --- /dev/null +++ b/coderd/database/migrations/000035_jwt_licenses.up.sql @@ -0,0 +1,7 @@ +-- 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; +ALTER TABLE licenses ADD COLUMN exp timestamp with time zone NOT NULL; + diff --git a/coderd/database/models.go b/coderd/database/models.go index 6cf4c07761674..c996704fb8f6c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -361,9 +361,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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 90e9a3a0a1385..a17f3040dd784 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -96,6 +96,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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d36262a121ee2..e35b51c4eb1e5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -508,6 +508,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" diff --git a/coderd/database/queries/licenses.sql b/coderd/database/queries/licenses.sql new file mode 100644 index 0000000000000..3add1b59e02c5 --- /dev/null +++ b/coderd/database/queries/licenses.sql @@ -0,0 +1,9 @@ +-- name: InsertLicense :one +INSERT INTO + licenses ( + uploaded_at, + jwt, + exp +) +VALUES + ($1, $2, $3) RETURNING *; diff --git a/coderd/features_internal_test.go b/coderd/features_internal_test.go index b86eb30dc8d8c..d8480899c6d84 100644 --- a/coderd/features_internal_test.go +++ b/coderd/features_internal_test.go @@ -6,9 +6,10 @@ import ( "net/http/httptest" "testing" - "github.com/coder/coder/codersdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/coder/coder/codersdk" ) func TestEntitlements(t *testing.T) { diff --git a/coderd/licenses.go b/coderd/licenses.go new file mode 100644 index 0000000000000..20cb87bad40d2 --- /dev/null +++ b/coderd/licenses.go @@ -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(licensesUnsupported) + return r +} + +func licensesUnsupported(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, + }) +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 0dff5218748da..45d084ea42313 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -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 diff --git a/codersdk/licenses.go b/codersdk/licenses.go new file mode 100644 index 0000000000000..c97c5885df891 --- /dev/null +++ b/codersdk/licenses.go @@ -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) +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go new file mode 100644 index 0000000000000..3ffd1938c0c7c --- /dev/null +++ b/enterprise/cli/root.go @@ -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.CoreSubcommands(), agpl.Server(coderd.NewEnterprise)) + return all +} diff --git a/enterprise/cmd/coder/main.go b/enterprise/cmd/coder/main.go index f90464bd2e625..795f4acbf4e01 100644 --- a/enterprise/cmd/coder/main.go +++ b/enterprise/cmd/coder/main.go @@ -10,12 +10,13 @@ import ( "github.com/coder/coder/cli" "github.com/coder/coder/cli/cliui" + ent "github.com/coder/coder/enterprise/cli" ) func main() { rand.Seed(time.Now().UnixMicro()) - cmd, err := cli.Root().ExecuteC() + cmd, err := cli.Root(ent.EnterpriseSubcommands()).ExecuteC() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go new file mode 100644 index 0000000000000..eb6524498d5e3 --- /dev/null +++ b/enterprise/coderd/coderd.go @@ -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) +} diff --git a/enterprise/coderd/keys/2022-08-12 b/enterprise/coderd/keys/2022-08-12 new file mode 100644 index 0000000000000..f1413b6eadeab --- /dev/null +++ b/enterprise/coderd/keys/2022-08-12 @@ -0,0 +1 @@ +gjޝ",! 6vh/cmί/ \ No newline at end of file diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go new file mode 100644 index 0000000000000..2a9042efca0ff --- /dev/null +++ b/enterprise/coderd/licenses.go @@ -0,0 +1,175 @@ +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"} + +//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 *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 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 + } + if err != nil { + return nil, err + } + 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 + } +} + +type LicenseAPI struct { + handler 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{handler: r, logger: l, database: db, pubsub: ps, auth: auth} + r.Post("/", a.postLicense) + return a +} + +func (a *LicenseAPI) Handler() http.Handler { + return a.handler +} + +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, + } +} diff --git a/enterprise/coderd/licenses_internal_test.go b/enterprise/coderd/licenses_internal_test.go new file mode 100644 index 0000000000000..303a3ba320696 --- /dev/null +++ b/enterprise/coderd/licenses_internal_test.go @@ -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.Fail() + } + }) + + 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.Fail() + } + }) +} + +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 +} diff --git a/go.mod b/go.mod index 4b8c596c4be95..3efe7b819e9a3 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index f43ade42c0d31..b40a80417b716 100644 --- a/go.sum +++ b/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= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6be2515c4f688..6d07430184bd7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 From ddb73329355fb8ae0c17777b20afc224a3f6b1e5 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 18 Aug 2022 14:58:53 -0700 Subject: [PATCH 2/7] Support interface{} types in generated Typescript Signed-off-by: Spike Curtis --- scripts/apitypings/main.go | 7 +++++++ site/src/api/typesGenerated.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index dcb0b962d1dba..b396115a69bf4 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -457,6 +457,13 @@ 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"}, nil + } + return TypescriptType{}, xerrors.New("only empty interface types are supported") } // These are all the other types we need to support. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6d07430184bd7..432be9c5194e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -172,6 +172,13 @@ export interface GoogleInstanceIdentityToken { readonly json_web_token: string } +// From codersdk/licenses.go +export interface License { + readonly id: number + readonly uploaded_at: string + readonly claims: Record +} + // From codersdk/users.go export interface LoginWithPasswordRequest { readonly email: string From 0d698e2018b8d021910b50bae187d9ce2a8b34ca Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 18 Aug 2022 15:51:09 -0700 Subject: [PATCH 3/7] Disable linting on empty interface any Signed-off-by: Spike Curtis --- scripts/apitypings/main.go | 11 +++++++++-- site/src/api/typesGenerated.ts | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index b396115a69bf4..b49115d718373 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -381,8 +381,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. @@ -461,7 +467,8 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { // only handle the empty interface for now intf := ty if intf.Empty() { - return TypescriptType{ValueType: "any"}, nil + return TypescriptType{ValueType: "any", + AboveTypeLine: indentedComment("eslint-disable-next-line")}, nil } return TypescriptType{}, xerrors.New("only empty interface types are supported") } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 512556ffad8ed..d328983b386c6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -176,6 +176,7 @@ export interface GoogleInstanceIdentityToken { export interface License { readonly id: number readonly uploaded_at: string + // eslint-disable-next-line readonly claims: Record } From 0fbe13056418ee3cefcca77ef99f75586d9e134b Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 19 Aug 2022 11:13:16 -0700 Subject: [PATCH 4/7] Code review updates Signed-off-by: Spike Curtis --- cli/server.go | 1 - ...enses.down.sql => 000037_jwt_licenses.down.sql} | 0 ..._licenses.up.sql => 000037_jwt_licenses.up.sql} | 2 ++ enterprise/cmd/coder/main.go | 4 ++-- enterprise/coderd/licenses.go | 14 ++++++++++---- enterprise/coderd/licenses_internal_test.go | 4 ++-- 6 files changed, 16 insertions(+), 9 deletions(-) rename coderd/database/migrations/{000036_jwt_licenses.down.sql => 000037_jwt_licenses.down.sql} (100%) rename coderd/database/migrations/{000036_jwt_licenses.up.sql => 000037_jwt_licenses.up.sql} (68%) diff --git a/cli/server.go b/cli/server.go index 7bf5ad3b2e1bb..8fbb2fbbd948e 100644 --- a/cli/server.go +++ b/cli/server.go @@ -299,7 +299,6 @@ func Server(builder CoderServerBuilder) *cobra.Command { TURNServer: turnServer, TracerProvider: tracerProvider, Telemetry: telemetry.NewNoop(), - //LicenseHandler: coderd.Licenses(), } if oauth2GithubClientSecret != "" { diff --git a/coderd/database/migrations/000036_jwt_licenses.down.sql b/coderd/database/migrations/000037_jwt_licenses.down.sql similarity index 100% rename from coderd/database/migrations/000036_jwt_licenses.down.sql rename to coderd/database/migrations/000037_jwt_licenses.down.sql diff --git a/coderd/database/migrations/000036_jwt_licenses.up.sql b/coderd/database/migrations/000037_jwt_licenses.up.sql similarity index 68% rename from coderd/database/migrations/000036_jwt_licenses.up.sql rename to coderd/database/migrations/000037_jwt_licenses.up.sql index aa5146acb579e..1b646b4ce1c4b 100644 --- a/coderd/database/migrations/000036_jwt_licenses.up.sql +++ b/coderd/database/migrations/000037_jwt_licenses.up.sql @@ -3,5 +3,7 @@ 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; +-- 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. ALTER TABLE licenses ADD COLUMN exp timestamp with time zone NOT NULL; diff --git a/enterprise/cmd/coder/main.go b/enterprise/cmd/coder/main.go index 795f4acbf4e01..9223fa0d04339 100644 --- a/enterprise/cmd/coder/main.go +++ b/enterprise/cmd/coder/main.go @@ -10,13 +10,13 @@ import ( "github.com/coder/coder/cli" "github.com/coder/coder/cli/cliui" - ent "github.com/coder/coder/enterprise/cli" + entcli "github.com/coder/coder/enterprise/cli" ) func main() { rand.Seed(time.Now().UnixMicro()) - cmd, err := cli.Root(ent.EnterpriseSubcommands()).ExecuteC() + cmd, err := cli.Root(entcli.EnterpriseSubcommands()).ExecuteC() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 2a9042efca0ff..f36abf0a1a006 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -43,6 +43,11 @@ type Features struct { 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"` @@ -63,6 +68,9 @@ func parseLicense(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, e 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 { @@ -73,9 +81,6 @@ func parseLicense(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, e } return claims, nil } - if err != nil { - return nil, err - } return nil, xerrors.New("unable to parse Claims") } @@ -105,7 +110,8 @@ func NewLicenseAPI( l slog.Logger, db database.Store, ps database.Pubsub, - auth *coderd.HTTPAuthorizer) *LicenseAPI { + auth *coderd.HTTPAuthorizer, +) *LicenseAPI { r := chi.NewRouter() a := &LicenseAPI{handler: r, logger: l, database: db, pubsub: ps, auth: auth} r.Post("/", a.postLicense) diff --git a/enterprise/coderd/licenses_internal_test.go b/enterprise/coderd/licenses_internal_test.go index 303a3ba320696..e1ac0d7af5ad3 100644 --- a/enterprise/coderd/licenses_internal_test.go +++ b/enterprise/coderd/licenses_internal_test.go @@ -101,7 +101,7 @@ func TestPostLicense(t *testing.T) { if xerrors.As(err, &errResp) { assert.Equal(t, 401, errResp.StatusCode()) } else { - t.Fail() + t.Error("expected to get error status 401") } }) @@ -137,7 +137,7 @@ func TestPostLicense(t *testing.T) { if xerrors.As(err, &errResp) { assert.Equal(t, 400, errResp.StatusCode()) } else { - t.Fail() + t.Error("expected to get error status 400") } }) } From a590d75693a5ec0469d84383d7b146f12f25d2ae Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 19 Aug 2022 12:35:32 -0700 Subject: [PATCH 5/7] Enforce unique licenses Signed-off-by: Spike Curtis --- coderd/database/dump.sql | 3 +++ coderd/database/migrations/000037_jwt_licenses.up.sql | 2 ++ 2 files changed, 5 insertions(+) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index cc2780debdf67..8c3e08a6b1995 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -379,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); diff --git a/coderd/database/migrations/000037_jwt_licenses.up.sql b/coderd/database/migrations/000037_jwt_licenses.up.sql index 1b646b4ce1c4b..0981cab5a209e 100644 --- a/coderd/database/migrations/000037_jwt_licenses.up.sql +++ b/coderd/database/migrations/000037_jwt_licenses.up.sql @@ -3,6 +3,8 @@ 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); -- 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. ALTER TABLE licenses ADD COLUMN exp timestamp with time zone NOT NULL; From 4d6dfcbafa2691c044c0c09a4ca7faabdeee492d Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 22 Aug 2022 11:49:33 -0700 Subject: [PATCH 6/7] Renames from code review Signed-off-by: Spike Curtis --- cli/clitest/clitest.go | 2 +- cli/root.go | 6 +++--- cli/server.go | 6 ++---- cmd/coder/main.go | 2 +- enterprise/cli/root.go | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 6ce6d74b20900..c37e652bef39f 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -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(cli.AGPLSubcommands()) + cmd := cli.Root(cli.AGPL()) dir := t.TempDir() root := config.Root(dir) cmd.SetArgs(append([]string{"--global-config", dir}, args...)) diff --git a/cli/root.go b/cli/root.go index 62c2ab0a27986..250052282bde9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -59,7 +59,7 @@ func init() { cobra.AddTemplateFuncs(templateFunctions) } -func CoreSubcommands() []*cobra.Command { +func Core() []*cobra.Command { return []*cobra.Command{ configSSH(), create(), @@ -89,8 +89,8 @@ func CoreSubcommands() []*cobra.Command { } } -func AGPLSubcommands() []*cobra.Command { - all := append(CoreSubcommands(), Server(coderd.New)) +func AGPL() []*cobra.Command { + all := append(Core(), Server(coderd.New)) return all } diff --git a/cli/server.go b/cli/server.go index 8fbb2fbbd948e..4e2212492b449 100644 --- a/cli/server.go +++ b/cli/server.go @@ -67,10 +67,8 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) -type CoderServerBuilder func(*coderd.Options) *coderd.API - // nolint:gocyclo -func Server(builder CoderServerBuilder) *cobra.Command { +func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command { var ( accessURL string address string @@ -436,7 +434,7 @@ func Server(builder CoderServerBuilder) *cobra.Command { ), promAddress, "prometheus")() } - coderAPI := builder(options) + coderAPI := newAPI(options) defer coderAPI.Close() client := codersdk.New(localURL) diff --git a/cmd/coder/main.go b/cmd/coder/main.go index a8b89abca9b43..177b3a469a21c 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -15,7 +15,7 @@ import ( func main() { rand.Seed(time.Now().UnixMicro()) - cmd, err := cli.Root(cli.AGPLSubcommands()).ExecuteC() + cmd, err := cli.Root(cli.AGPL()).ExecuteC() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 3ffd1938c0c7c..114c283fa6f2e 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -8,6 +8,6 @@ import ( ) func EnterpriseSubcommands() []*cobra.Command { - all := append(agpl.CoreSubcommands(), agpl.Server(coderd.NewEnterprise)) + all := append(agpl.Core(), agpl.Server(coderd.NewEnterprise)) return all } From ba9167eaf234ec085043a5d9460ecab0fc71f519 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 22 Aug 2022 14:45:42 -0700 Subject: [PATCH 7/7] Code review renames and comments Signed-off-by: Spike Curtis --- coderd/coderd.go | 2 +- coderd/database/databasefake/databasefake.go | 2 +- .../migrations/000037_jwt_licenses.up.sql | 3 +- coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 6 ++-- coderd/database/sqlc.yaml | 1 + coderd/licenses.go | 6 ++-- enterprise/coderd/coderd.go | 4 +-- enterprise/coderd/licenses.go | 31 +++++++++++++------ 9 files changed, 35 insertions(+), 22 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 7623d562e888d..a2062b736227a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -94,7 +94,7 @@ func New(options *Options) *API { options.PrometheusRegistry = prometheus.NewRegistry() } if options.LicenseHandler == nil { - options.LicenseHandler = Licenses() + options.LicenseHandler = licenses() } siteCacheDir := options.CacheDir diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 837d8a117b118..7ba138c820312 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2288,7 +2288,7 @@ func (q *fakeQuerier) InsertLicense( l := database.License{ ID: q.lastLicenseID + 1, UploadedAt: arg.UploadedAt, - Jwt: arg.Jwt, + JWT: arg.JWT, Exp: arg.Exp, } q.lastLicenseID = l.ID diff --git a/coderd/database/migrations/000037_jwt_licenses.up.sql b/coderd/database/migrations/000037_jwt_licenses.up.sql index 0981cab5a209e..d71ac41d9e977 100644 --- a/coderd/database/migrations/000037_jwt_licenses.up.sql +++ b/coderd/database/migrations/000037_jwt_licenses.up.sql @@ -5,7 +5,6 @@ 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); --- 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. 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.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 63b2c18e3ea24..6614a3d6ddb78 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -359,7 +359,7 @@ type GitSSHKey struct { type License struct { ID int32 `db:"id" json:"id"` UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` - Jwt string `db:"jwt" json:"jwt"` + JWT string `db:"jwt" json:"jwt"` Exp time.Time `db:"exp" json:"exp"` } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0d21cd4f86acd..3e6643781d30a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -488,17 +488,17 @@ VALUES type InsertLicenseParams struct { UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` - Jwt string `db:"jwt" json:"jwt"` + 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) + 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.JWT, &i.Exp, ) return i, err diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 6f42bbaa4bd2c..53bae1099b974 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -35,3 +35,4 @@ rename: rbac_roles: RBACRoles ip_address: IPAddress wireguard_node_ipv6: WireguardNodeIPv6 + jwt: JWT diff --git a/coderd/licenses.go b/coderd/licenses.go index 20cb87bad40d2..28a0b1d418043 100644 --- a/coderd/licenses.go +++ b/coderd/licenses.go @@ -9,13 +9,13 @@ import ( "github.com/coder/coder/codersdk" ) -func Licenses() http.Handler { +func licenses() http.Handler { r := chi.NewRouter() - r.NotFound(licensesUnsupported) + r.NotFound(unsupported) return r } -func licensesUnsupported(rw http.ResponseWriter, _ *http.Request) { +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", diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index eb6524498d5e3..2be49052d3658 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -18,13 +18,13 @@ func NewEnterprise(options *coderd.Options) *coderd.API { panic(xerrors.Errorf("rego authorize panic: %w", err)) } } - eOpts.LicenseHandler = NewLicenseAPI( + eOpts.LicenseHandler = newLicenseAPI( eOpts.Logger, eOpts.Database, eOpts.Pubsub, &coderd.HTTPAuthorizer{ Authorizer: eOpts.Authorizer, Logger: eOpts.Logger, - }).Handler() + }).handler() return coderd.New(&eOpts) } diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index f36abf0a1a006..630c1ffe18619 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -31,6 +31,8 @@ const ( 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 @@ -98,31 +100,42 @@ func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, e } } -type LicenseAPI struct { - handler chi.Router +// 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( +func newLicenseAPI( l slog.Logger, db database.Store, ps database.Pubsub, auth *coderd.HTTPAuthorizer, -) *LicenseAPI { +) *licenseAPI { r := chi.NewRouter() - a := &LicenseAPI{handler: r, logger: l, database: db, pubsub: ps, auth: auth} + 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.handler +func (a *licenseAPI) handler() http.Handler { + return a.router } -func (a *LicenseAPI) postLicense(rw http.ResponseWriter, r *http.Request) { +// 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 @@ -153,7 +166,7 @@ func (a *LicenseAPI) postLicense(rw http.ResponseWriter, r *http.Request) { dl, err := a.database.InsertLicense(r.Context(), database.InsertLicenseParams{ UploadedAt: database.Now(), - Jwt: addLicense.License, + JWT: addLicense.License, Exp: expTime, }) if err != nil {