POST license API endpoint (#3570)

* POST license API

Signed-off-by: Spike Curtis <spike@coder.com>

* Support interface{} types in generated Typescript

Signed-off-by: Spike Curtis <spike@coder.com>

* Disable linting on empty interface any

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review updates

Signed-off-by: Spike Curtis <spike@coder.com>

* Enforce unique licenses

Signed-off-by: Spike Curtis <spike@coder.com>

* Renames from code review

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review renames and comments

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
This commit is contained in:
Spike Curtis
2022-08-22 15:02:50 -07:00
committed by GitHub
parent 85acfdf0dc
commit b101a6f3f4
29 changed files with 666 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
coderd/licenses.go Normal file
View File

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

View File

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