diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 53cf6b28a9..c37e652bef 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.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 74a37ac4b6..250052282b 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 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()) diff --git a/cli/server.go b/cli/server.go index 9a9d464b4c..f3f1076066 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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 } diff --git a/cmd/coder/main.go b/cmd/coder/main.go index f90464bd2e..177b3a469a 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.AGPL()).ExecuteC() if err != nil { if errors.Is(err, cliui.Canceled) { os.Exit(1) diff --git a/coderd/authorize.go b/coderd/authorize.go index 981af5e868..b68e1b6854 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. @@ -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 diff --git a/coderd/coderd.go b/coderd/coderd.go index cf29aa986f..a2062b7362 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 90f9482999..d8d5cc97ea 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 1e39e0a2fa..7ba138c820 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), }, } } @@ -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() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7ca153c7f5..8c3e08a6b1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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); diff --git a/coderd/database/migrations/000037_jwt_licenses.down.sql b/coderd/database/migrations/000037_jwt_licenses.down.sql new file mode 100644 index 0000000000..274dabae7a --- /dev/null +++ b/coderd/database/migrations/000037_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/000037_jwt_licenses.up.sql b/coderd/database/migrations/000037_jwt_licenses.up.sql new file mode 100644 index 0000000000..d71ac41d9e --- /dev/null +++ b/coderd/database/migrations/000037_jwt_licenses.up.sql @@ -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.'; + diff --git a/coderd/database/models.go b/coderd/database/models.go index 68c4414664..6614a3d6dd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2cc1a2b084..cddf33e33d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 490b888479..3e6643781d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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" diff --git a/coderd/database/queries/licenses.sql b/coderd/database/queries/licenses.sql new file mode 100644 index 0000000000..3add1b59e0 --- /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/database/sqlc.yaml b/coderd/database/sqlc.yaml index 6f42bbaa4b..53bae1099b 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 new file mode 100644 index 0000000000..28a0b1d418 --- /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(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, + }) +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 0dff521874..45d084ea42 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 0000000000..c97c5885df --- /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 0000000000..114c283fa6 --- /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.Core(), agpl.Server(coderd.NewEnterprise)) + return all +} diff --git a/enterprise/cmd/coder/main.go b/enterprise/cmd/coder/main.go index f90464bd2e..9223fa0d04 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" + 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) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go new file mode 100644 index 0000000000..2be49052d3 --- /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 0000000000..f1413b6ead --- /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 0000000000..630c1ffe18 --- /dev/null +++ b/enterprise/coderd/licenses.go @@ -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, + } +} diff --git a/enterprise/coderd/licenses_internal_test.go b/enterprise/coderd/licenses_internal_test.go new file mode 100644 index 0000000000..e1ac0d7af5 --- /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.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 +} diff --git a/go.mod b/go.mod index 38163dd09a..2e5ed24c4a 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 f43ade42c0..b40a80417b 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/scripts/apitypings/main.go b/scripts/apitypings/main.go index 8cea09d09f..1159a7a6e4 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -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. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d4a7a79dcb..38ee495dbf 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 @@ -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 +} + // From codersdk/users.go export interface LoginWithPasswordRequest { readonly email: string