diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 53cf6b28a9016..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() + 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 74a37ac4b6d8b..250052282bde9 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 126ea0bc8204b..4e2212492b449 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) @@ -885,16 +885,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..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().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 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..a2062b736227a 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 1e39e0a2fa54b..7ba138c820312 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 7ca153c7f562d..8c3e08a6b1995 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 0000000000000..274dabae7a457 --- /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 0000000000000..d71ac41d9e977 --- /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 68c4414664f02..6614a3d6ddb78 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 2cc1a2b084ad5..cddf33e33d427 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 490b888479817..3e6643781d30a 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 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/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 new file mode 100644 index 0000000000000..28a0b1d418043 --- /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 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..114c283fa6f2e --- /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 f90464bd2e625..9223fa0d04339 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 0000000000000..2be49052d3658 --- /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..630c1ffe18619 --- /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 0000000000000..e1ac0d7af5ad3 --- /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 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/scripts/apitypings/main.go b/scripts/apitypings/main.go index dcb0b962d1dba..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. @@ -457,6 +463,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 ccd4cc2a08c47..9a49bc23ec5b2 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