mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
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:
@ -27,6 +27,11 @@ func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Act
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
type HTTPAuthorizer struct {
|
||||
Authorizer rbac.Authorizer
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
// Authorize will return false if the user is not authorized to do the action.
|
||||
// This function will log appropriately, but the caller must return an
|
||||
// error to the api client.
|
||||
@ -37,14 +42,26 @@ func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Act
|
||||
// return
|
||||
// }
|
||||
func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
|
||||
return api.httpAuth.Authorize(r, action, object)
|
||||
}
|
||||
|
||||
// Authorize will return false if the user is not authorized to do the action.
|
||||
// This function will log appropriately, but the caller must return an
|
||||
// error to the api client.
|
||||
// Eg:
|
||||
// if !h.Authorize(...) {
|
||||
// httpapi.Forbidden(rw)
|
||||
// return
|
||||
// }
|
||||
func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
|
||||
roles := httpmw.AuthorizationUserRoles(r)
|
||||
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
|
||||
err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
|
||||
if err != nil {
|
||||
// Log the errors for debugging
|
||||
internalError := new(rbac.UnauthorizedError)
|
||||
logger := api.Logger
|
||||
logger := h.Logger
|
||||
if xerrors.As(err, internalError) {
|
||||
logger = api.Logger.With(slog.F("internal", internalError.Internal()))
|
||||
logger = h.Logger.With(slog.F("internal", internalError.Internal()))
|
||||
}
|
||||
// Log information for debugging. This will be very helpful
|
||||
// in the early days
|
||||
|
@ -66,6 +66,7 @@ type Options struct {
|
||||
Telemetry telemetry.Reporter
|
||||
TURNServer *turnconn.Server
|
||||
TracerProvider *sdktrace.TracerProvider
|
||||
LicenseHandler http.Handler
|
||||
}
|
||||
|
||||
// New constructs a Coder API handler.
|
||||
@ -92,6 +93,9 @@ func New(options *Options) *API {
|
||||
if options.PrometheusRegistry == nil {
|
||||
options.PrometheusRegistry = prometheus.NewRegistry()
|
||||
}
|
||||
if options.LicenseHandler == nil {
|
||||
options.LicenseHandler = licenses()
|
||||
}
|
||||
|
||||
siteCacheDir := options.CacheDir
|
||||
if siteCacheDir != "" {
|
||||
@ -107,6 +111,10 @@ func New(options *Options) *API {
|
||||
Options: options,
|
||||
Handler: r,
|
||||
siteHandler: site.Handler(site.FS(), binFS),
|
||||
httpAuth: &HTTPAuthorizer{
|
||||
Authorizer: options.Authorizer,
|
||||
Logger: options.Logger,
|
||||
},
|
||||
}
|
||||
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
|
||||
oauthConfigs := &httpmw.OAuth2Configs{
|
||||
@ -395,6 +403,10 @@ func New(options *Options) *API {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/", entitlements)
|
||||
})
|
||||
r.Route("/licenses", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Mount("/", options.LicenseHandler)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP)
|
||||
@ -409,6 +421,7 @@ type API struct {
|
||||
websocketWaitMutex sync.Mutex
|
||||
websocketWaitGroup sync.WaitGroup
|
||||
workspaceAgentCache *wsconncache.Cache
|
||||
httpAuth *HTTPAuthorizer
|
||||
}
|
||||
|
||||
// Close waits for all WebSocket connections to drain before returning.
|
||||
|
@ -73,6 +73,7 @@ type Options struct {
|
||||
|
||||
// IncludeProvisionerD when true means to start an in-memory provisionerD
|
||||
IncludeProvisionerD bool
|
||||
APIBuilder func(*coderd.Options) *coderd.API
|
||||
}
|
||||
|
||||
// New constructs a codersdk client connected to an in-memory API instance.
|
||||
@ -122,6 +123,9 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
|
||||
close(options.AutobuildStats)
|
||||
})
|
||||
}
|
||||
if options.APIBuilder == nil {
|
||||
options.APIBuilder = coderd.New
|
||||
}
|
||||
|
||||
// This can be hotswapped for a live database instance.
|
||||
db := databasefake.New()
|
||||
@ -177,7 +181,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
|
||||
})
|
||||
|
||||
// We set the handler after server creation for the access URL.
|
||||
coderAPI := coderd.New(&coderd.Options{
|
||||
coderAPI := options.APIBuilder(&coderd.Options{
|
||||
AgentConnectionUpdateFrequency: 150 * time.Millisecond,
|
||||
// Force a long disconnection timeout to ensure
|
||||
// agents are not marked as disconnected during slow tests.
|
||||
|
@ -42,6 +42,7 @@ func New() database.Store {
|
||||
workspaceBuilds: make([]database.WorkspaceBuild, 0),
|
||||
workspaceApps: make([]database.WorkspaceApp, 0),
|
||||
workspaces: make([]database.Workspace, 0),
|
||||
licenses: make([]database.License, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -92,8 +93,10 @@ type data struct {
|
||||
workspaceBuilds []database.WorkspaceBuild
|
||||
workspaceApps []database.WorkspaceApp
|
||||
workspaces []database.Workspace
|
||||
licenses []database.License
|
||||
|
||||
deploymentID string
|
||||
deploymentID string
|
||||
lastLicenseID int32
|
||||
}
|
||||
|
||||
// InTx doesn't rollback data properly for in-memory yet.
|
||||
@ -2277,6 +2280,22 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) {
|
||||
return q.deploymentID, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertLicense(
|
||||
_ context.Context, arg database.InsertLicenseParams) (database.License, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
l := database.License{
|
||||
ID: q.lastLicenseID + 1,
|
||||
UploadedAt: arg.UploadedAt,
|
||||
JWT: arg.JWT,
|
||||
Exp: arg.Exp,
|
||||
}
|
||||
q.lastLicenseID = l.ID
|
||||
q.licenses = append(q.licenses, l)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
8
coderd/database/dump.sql
generated
8
coderd/database/dump.sql
generated
@ -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);
|
||||
|
||||
|
7
coderd/database/migrations/000037_jwt_licenses.down.sql
Normal file
7
coderd/database/migrations/000037_jwt_licenses.down.sql
Normal 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;
|
||||
|
10
coderd/database/migrations/000037_jwt_licenses.up.sql
Normal file
10
coderd/database/migrations/000037_jwt_licenses.up.sql
Normal 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.';
|
||||
|
@ -357,9 +357,10 @@ type GitSSHKey struct {
|
||||
}
|
||||
|
||||
type License struct {
|
||||
ID int32 `db:"id" json:"id"`
|
||||
License json.RawMessage `db:"license" json:"license"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
ID int32 `db:"id" json:"id"`
|
||||
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
|
||||
JWT string `db:"jwt" json:"jwt"`
|
||||
Exp time.Time `db:"exp" json:"exp"`
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
|
@ -99,6 +99,7 @@ type querier interface {
|
||||
InsertDeploymentID(ctx context.Context, value string) error
|
||||
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
|
||||
InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error)
|
||||
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
|
||||
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
|
||||
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
|
||||
InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error)
|
||||
|
@ -475,6 +475,35 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar
|
||||
return err
|
||||
}
|
||||
|
||||
const insertLicense = `-- name: InsertLicense :one
|
||||
INSERT INTO
|
||||
licenses (
|
||||
uploaded_at,
|
||||
jwt,
|
||||
exp
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3) RETURNING id, uploaded_at, jwt, exp
|
||||
`
|
||||
|
||||
type InsertLicenseParams struct {
|
||||
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
|
||||
JWT string `db:"jwt" json:"jwt"`
|
||||
Exp time.Time `db:"exp" json:"exp"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertLicense, arg.UploadedAt, arg.JWT, arg.Exp)
|
||||
var i License
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UploadedAt,
|
||||
&i.JWT,
|
||||
&i.Exp,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many
|
||||
SELECT
|
||||
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"
|
||||
|
9
coderd/database/queries/licenses.sql
Normal file
9
coderd/database/queries/licenses.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- name: InsertLicense :one
|
||||
INSERT INTO
|
||||
licenses (
|
||||
uploaded_at,
|
||||
jwt,
|
||||
exp
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3) RETURNING *;
|
@ -35,3 +35,4 @@ rename:
|
||||
rbac_roles: RBACRoles
|
||||
ip_address: IPAddress
|
||||
wireguard_node_ipv6: WireguardNodeIPv6
|
||||
jwt: JWT
|
||||
|
24
coderd/licenses.go
Normal file
24
coderd/licenses.go
Normal 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,
|
||||
})
|
||||
}
|
@ -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
|
||||
|
Reference in New Issue
Block a user