Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f02a188f3c |
@@ -679,7 +679,7 @@ gen/db: $(DB_GEN_FILES)
|
||||
gen/golden-files: \
|
||||
agent/unit/testdata/.gen-golden \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/insightsapi/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
enterprise/cli/testdata/.gen-golden \
|
||||
enterprise/tailnet/testdata/.gen-golden \
|
||||
@@ -953,7 +953,7 @@ clean/golden-files:
|
||||
find \
|
||||
cli/testdata \
|
||||
coderd/notifications/testdata \
|
||||
coderd/testdata \
|
||||
coderd/insightsapi/testdata \
|
||||
enterprise/cli/testdata \
|
||||
enterprise/tailnet/testdata \
|
||||
helm/coder/tests/testdata \
|
||||
@@ -991,8 +991,8 @@ helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/t
|
||||
TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update
|
||||
touch "$@"
|
||||
|
||||
coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/*_test.go)
|
||||
TZ=UTC go test ./coderd -run="Test.*Golden$$" -update
|
||||
coderd/insightsapi/.gen-golden: $(wildcard coderd/insightsapi/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/insightsapi/*_test.go)
|
||||
TZ=UTC go test ./coderd/insightsapi -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/notifications/*_test.go)
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
@@ -341,7 +342,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
keys, err = AuthorizeFilter(api.HTTPAuth, r, policy.ActionRead, keys)
|
||||
keys, err = httpauthz.AuthorizationFilter(api.HTTPAuth, r, policy.ActionRead, keys)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching keys.",
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
@@ -15,33 +14,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// AuthorizeFilter takes a list of objects and returns the filtered list of
|
||||
// objects that the user is authorized to perform the given action on.
|
||||
// This is faster than calling Authorize() on each object.
|
||||
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action policy.Action, objects []O) ([]O, error) {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles, action, objects)
|
||||
if err != nil {
|
||||
// Log the error as Filter should not be erroring.
|
||||
h.Logger.Error(r.Context(), "authorization filter failed",
|
||||
slog.Error(err),
|
||||
slog.F("user_id", roles.ID),
|
||||
slog.F("username", roles),
|
||||
slog.F("roles", roles.SafeRoleNames()),
|
||||
slog.F("scope", roles.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
slog.F("action", action),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
@@ -55,57 +27,6 @@ func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Obj
|
||||
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 policy.Action, object rbac.Objecter) bool {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
err := h.Authorizer.Authorize(r.Context(), roles, action, object.RBACObject())
|
||||
if err != nil {
|
||||
// Log the errors for debugging
|
||||
internalError := new(rbac.UnauthorizedError)
|
||||
logger := h.Logger
|
||||
if xerrors.As(err, internalError) {
|
||||
logger = h.Logger.With(slog.F("internal_error", internalError.Internal()))
|
||||
}
|
||||
// Log information for debugging. This will be very helpful
|
||||
// in the early days
|
||||
logger.Warn(r.Context(), "requester is not authorized to access the object",
|
||||
slog.F("roles", roles.SafeRoleNames()),
|
||||
slog.F("actor_id", roles.ID),
|
||||
slog.F("actor_name", roles),
|
||||
slog.F("scope", roles.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
slog.F("action", action),
|
||||
slog.F("object", object),
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizeSQLFilter returns an authorization filter that can used in a
|
||||
// SQL 'WHERE' clause. If the filter is used, the resulting rows returned
|
||||
// from postgres are already authorized, and the caller does not need to
|
||||
// call 'Authorize()' on the returned objects.
|
||||
// Note the authorization is only for the given action and object type.
|
||||
func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
prepared, err := h.Authorizer.Prepare(r.Context(), roles, action, objectType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("prepare filter: %w", err)
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// checkAuthorization returns if the current API key can use the given
|
||||
// permissions, factoring in the current user's roles and the API key scopes.
|
||||
//
|
||||
|
||||
+13
-7
@@ -21,6 +21,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
"github.com/coder/coder/v2/coderd/insightsapi"
|
||||
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||
"github.com/coder/coder/v2/coderd/pproflabel"
|
||||
"github.com/coder/coder/v2/coderd/prebuilds"
|
||||
@@ -584,7 +586,7 @@ func New(options *Options) *API {
|
||||
ID: uuid.New(),
|
||||
Options: options,
|
||||
RootHandler: r,
|
||||
HTTPAuth: &HTTPAuthorizer{
|
||||
HTTPAuth: &httpauthz.HTTPAuthorizer{
|
||||
Authorizer: options.Authorizer,
|
||||
Logger: options.Logger,
|
||||
},
|
||||
@@ -800,6 +802,8 @@ func New(options *Options) *API {
|
||||
APIKeyEncryptionKeycache: options.AppEncryptionKeyCache,
|
||||
})
|
||||
|
||||
api.insightsAPI = insightsapi.NewAPI(api.Logger, api.Database, api.HTTPAuth)
|
||||
|
||||
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
ActivateDormantUser: ActivateDormantUser(options.Logger, &api.Auditor, options.Database),
|
||||
@@ -1524,11 +1528,11 @@ func New(options *Options) *API {
|
||||
})
|
||||
r.Route("/insights", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/daus", api.deploymentDAUs)
|
||||
r.Get("/user-activity", api.insightsUserActivity)
|
||||
r.Get("/user-status-counts", api.insightsUserStatusCounts)
|
||||
r.Get("/user-latency", api.insightsUserLatency)
|
||||
r.Get("/templates", api.insightsTemplates)
|
||||
r.Get("/daus", api.insightsAPI.DeploymentDAUs)
|
||||
r.Get("/user-activity", api.insightsAPI.UserActivity)
|
||||
r.Get("/user-status-counts", api.insightsAPI.UserStatusCounts)
|
||||
r.Get("/user-latency", api.insightsAPI.UserLatency)
|
||||
r.Get("/templates", api.insightsAPI.Templates)
|
||||
})
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Use(
|
||||
@@ -1802,7 +1806,7 @@ type API struct {
|
||||
|
||||
UpdatesProvider tailnet.WorkspaceUpdatesProvider
|
||||
|
||||
HTTPAuth *HTTPAuthorizer
|
||||
HTTPAuth *httpauthz.HTTPAuthorizer
|
||||
|
||||
// APIHandler serves "/api/v2"
|
||||
APIHandler chi.Router
|
||||
@@ -1837,6 +1841,8 @@ type API struct {
|
||||
// dbRolluper rolls up template usage stats from raw agent and app
|
||||
// stats. This is used to provide insights in the WebUI.
|
||||
dbRolluper *dbrollup.Rolluper
|
||||
|
||||
insightsAPI *insightsapi.API
|
||||
}
|
||||
|
||||
// Close waits for all WebSocket connections to drain before returning.
|
||||
|
||||
@@ -2,7 +2,6 @@ package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -35,9 +34,6 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// updateGoldenFiles is a flag that can be set to update golden files.
|
||||
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package coderdtest
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
)
|
||||
|
||||
func MockedDatabaseWithAuthz(t testing.TB, logger slog.Logger) (*gomock.Controller, *dbmock.MockStore, database.Store, rbac.Authorizer) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
mDB.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
authDB := dbauthz.New(mDB, auth, logger, accessControlStore)
|
||||
return ctrl, mDB, authDB, auth
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package coderdtest
|
||||
|
||||
import "github.com/coder/coder/v2/coderd/rbac"
|
||||
|
||||
func OwnerSubject() rbac.Subject {
|
||||
return rbac.Subject{
|
||||
FriendlyName: "coderdtest-owner",
|
||||
Email: "owner@coderd.test",
|
||||
Type: rbac.SubjectTypeUser,
|
||||
ID: "coderdtest-owner-id",
|
||||
Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestEndpointsDocumented(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
swaggerComments, err := coderdtest.ParseSwaggerComments("..")
|
||||
swaggerComments, err := coderdtest.ParseSwaggerComments("..", "../insightsapi")
|
||||
require.NoError(t, err, "can't parse swagger comments")
|
||||
require.NotEmpty(t, swaggerComments, "swagger comments must be present")
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpauthz
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
)
|
||||
|
||||
// AuthorizationFilter takes a list of objects and returns the filtered list of
|
||||
// objects that the user is authorized to perform the given action on.
|
||||
// This is faster than calling Authorize() on each object.
|
||||
func AuthorizationFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action policy.Action, objects []O) ([]O, error) {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles, action, objects)
|
||||
if err != nil {
|
||||
// Log the error as Filter should not be erroring.
|
||||
h.Logger.Error(r.Context(), "authorization filter failed",
|
||||
slog.Error(err),
|
||||
slog.F("user_id", roles.ID),
|
||||
slog.F("username", roles),
|
||||
slog.F("roles", roles.SafeRoleNames()),
|
||||
slog.F("scope", roles.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
slog.F("action", action),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
type HTTPAuthorizer struct {
|
||||
Authorizer rbac.Authorizer
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
// AuthorizeSQLFilter returns an authorization filter that can used in a
|
||||
// SQL 'WHERE' clause. If the filter is used, the resulting rows returned
|
||||
// from postgres are already authorized, and the caller does not need to
|
||||
// call 'Authorize()' on the returned objects.
|
||||
// Note the authorization is only for the given action and object type.
|
||||
func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
prepared, err := h.Authorizer.Prepare(r.Context(), roles, action, objectType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("prepare filter: %w", err)
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// 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 policy.Action, object rbac.Objecter) bool {
|
||||
roles := httpmw.UserAuthorization(r.Context())
|
||||
err := h.Authorizer.Authorize(r.Context(), roles, action, object.RBACObject())
|
||||
if err != nil {
|
||||
// Log the errors for debugging
|
||||
internalError := new(rbac.UnauthorizedError)
|
||||
logger := h.Logger
|
||||
if xerrors.As(err, internalError) {
|
||||
logger = h.Logger.With(slog.F("internal_error", internalError.Internal()))
|
||||
}
|
||||
// Log information for debugging. This will be very helpful
|
||||
// in the early days
|
||||
logger.Warn(r.Context(), "requester is not authorized to access the object",
|
||||
slog.F("roles", roles.SafeRoleNames()),
|
||||
slog.F("actor_id", roles.ID),
|
||||
slog.F("actor_name", roles),
|
||||
slog.F("scope", roles.SafeScopeName()),
|
||||
slog.F("route", r.URL.Path),
|
||||
slog.F("action", action),
|
||||
slog.F("object", object),
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package insightsapi
|
||||
|
||||
import (
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
authorizer *httpauthz.HTTPAuthorizer
|
||||
database database.Store
|
||||
}
|
||||
|
||||
func NewAPI(
|
||||
logger slog.Logger,
|
||||
db database.Store,
|
||||
authorizer *httpauthz.HTTPAuthorizer,
|
||||
) *API {
|
||||
a := &API{
|
||||
logger: logger.Named("insightsapi"),
|
||||
authorizer: authorizer,
|
||||
database: db,
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package coderd
|
||||
package insightsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -34,16 +34,16 @@ const insightsTimeLayout = time.RFC3339
|
||||
// @Param tz_offset query int true "Time-zone offset (e.g. -2)"
|
||||
// @Success 200 {object} codersdk.DAUsResponse
|
||||
// @Router /insights/daus [get]
|
||||
func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
func (a *API) DeploymentDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
if !a.authorizer.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
api.returnDAUsInternal(rw, r, nil)
|
||||
a.DAUsForTemplates(rw, r, nil)
|
||||
}
|
||||
|
||||
func (api *API) returnDAUsInternal(rw http.ResponseWriter, r *http.Request, templateIDs []uuid.UUID) {
|
||||
func (a *API) DAUsForTemplates(rw http.ResponseWriter, r *http.Request, templateIDs []uuid.UUID) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := httpapi.NewQueryParamParser()
|
||||
@@ -66,7 +66,7 @@ func (api *API) returnDAUsInternal(rw http.ResponseWriter, r *http.Request, temp
|
||||
// Always return 60 days of data (2 months).
|
||||
sixtyDaysAgo := nextHourInLoc.In(loc).Truncate(24*time.Hour).AddDate(0, 0, -60)
|
||||
|
||||
rows, err := api.Database.GetTemplateInsightsByInterval(ctx, database.GetTemplateInsightsByIntervalParams{
|
||||
rows, err := a.database.GetTemplateInsightsByInterval(ctx, database.GetTemplateInsightsByIntervalParams{
|
||||
StartTime: sixtyDaysAgo,
|
||||
EndTime: nextHourInLoc,
|
||||
IntervalDays: 1,
|
||||
@@ -107,7 +107,7 @@ func (api *API) returnDAUsInternal(rw http.ResponseWriter, r *http.Request, temp
|
||||
// @Param template_ids query []string false "Template IDs" collectionFormat(csv)
|
||||
// @Success 200 {object} codersdk.UserActivityInsightsResponse
|
||||
// @Router /insights/user-activity [get]
|
||||
func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) UserActivity(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := httpapi.NewQueryParamParser().
|
||||
@@ -135,7 +135,7 @@ func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := api.Database.GetUserActivityInsights(ctx, database.GetUserActivityInsightsParams{
|
||||
rows, err := a.database.GetUserActivityInsights(ctx, database.GetUserActivityInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -210,7 +210,7 @@ func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Param template_ids query []string false "Template IDs" collectionFormat(csv)
|
||||
// @Success 200 {object} codersdk.UserLatencyInsightsResponse
|
||||
// @Router /insights/user-latency [get]
|
||||
func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) UserLatency(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := httpapi.NewQueryParamParser().
|
||||
@@ -238,7 +238,7 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := api.Database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{
|
||||
rows, err := a.database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -301,7 +301,7 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Param tz_offset query int true "Time-zone offset (e.g. -2)"
|
||||
// @Success 200 {object} codersdk.GetUserStatusCountsResponse
|
||||
// @Router /insights/user-status-counts [get]
|
||||
func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) UserStatusCounts(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := httpapi.NewQueryParamParser()
|
||||
@@ -322,7 +322,7 @@ func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request
|
||||
nextHourInLoc := dbtime.Now().Truncate(time.Hour).Add(time.Hour).In(loc)
|
||||
sixtyDaysAgo := dbtime.StartOfDay(nextHourInLoc).AddDate(0, 0, -60)
|
||||
|
||||
rows, err := api.Database.GetUserStatusCounts(ctx, database.GetUserStatusCountsParams{
|
||||
rows, err := a.database.GetUserStatusCounts(ctx, database.GetUserStatusCountsParams{
|
||||
StartTime: sixtyDaysAgo,
|
||||
EndTime: nextHourInLoc,
|
||||
// #nosec G115 - Interval value is small and fits in int32 (typically days or hours)
|
||||
@@ -366,7 +366,7 @@ func (api *API) insightsUserStatusCounts(rw http.ResponseWriter, r *http.Request
|
||||
// @Param template_ids query []string false "Template IDs" collectionFormat(csv)
|
||||
// @Success 200 {object} codersdk.TemplateInsightsResponse
|
||||
// @Router /insights/templates [get]
|
||||
func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) Templates(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := httpapi.NewQueryParamParser().
|
||||
@@ -418,7 +418,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
if interval != "" && slices.Contains(sections, codersdk.TemplateInsightsSectionIntervalReports) {
|
||||
dailyUsage, err = api.Database.GetTemplateInsightsByInterval(egCtx, database.GetTemplateInsightsByIntervalParams{
|
||||
dailyUsage, err = a.database.GetTemplateInsightsByInterval(egCtx, database.GetTemplateInsightsByIntervalParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -436,7 +436,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var err error
|
||||
usage, err = api.Database.GetTemplateInsights(egCtx, database.GetTemplateInsightsParams{
|
||||
usage, err = a.database.GetTemplateInsights(egCtx, database.GetTemplateInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -452,7 +452,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var err error
|
||||
appUsage, err = api.Database.GetTemplateAppInsights(egCtx, database.GetTemplateAppInsightsParams{
|
||||
appUsage, err = a.database.GetTemplateAppInsights(egCtx, database.GetTemplateAppInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -471,7 +471,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var err error
|
||||
parameterRows, err = api.Database.GetTemplateParameterInsights(ctx, database.GetTemplateParameterInsightsParams{
|
||||
parameterRows, err = a.database.GetTemplateParameterInsights(ctx, database.GetTemplateParameterInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
@@ -1,4 +1,4 @@
|
||||
package coderd
|
||||
package insightsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,11 +1,13 @@
|
||||
package coderd_test
|
||||
package insightsapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,9 +18,12 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
@@ -28,6 +33,8 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbrollup"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
"github.com/coder/coder/v2/coderd/insightsapi"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
@@ -39,6 +46,13 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// updateGoldenFiles is a flag that can be set to update golden files.
|
||||
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
||||
}
|
||||
|
||||
func TestDeploymentInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -142,6 +156,52 @@ func TestDeploymentInsights(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentDAUs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := testutil.Logger(t).Leveled(slog.LevelDebug)
|
||||
tzOffset := 4
|
||||
loc := time.FixedZone("", tzOffset*3600)
|
||||
|
||||
_, mDB, authDB, auth := coderdtest.MockedDatabaseWithAuthz(t, logger)
|
||||
mDB.EXPECT().GetTemplateInsightsByInterval(gomock.Any(), gomock.Cond(func(arg database.GetTemplateInsightsByIntervalParams) bool {
|
||||
return len(arg.TemplateIDs) == 0 &&
|
||||
arg.IntervalDays == 1
|
||||
})).
|
||||
Times(1).
|
||||
Return([]database.GetTemplateInsightsByIntervalRow{
|
||||
{
|
||||
StartTime: time.Date(2025, 12, 1, 0, 0, 0, 0, loc),
|
||||
EndTime: time.Date(2025, 12, 1, 23, 59, 59, 0, loc),
|
||||
ActiveUsers: 3,
|
||||
},
|
||||
{
|
||||
StartTime: time.Date(2025, 12, 2, 0, 0, 0, 0, loc),
|
||||
EndTime: time.Date(2025, 12, 2, 23, 59, 59, 0, loc),
|
||||
ActiveUsers: 30,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
uut := insightsapi.NewAPI(logger, authDB, &httpauthz.HTTPAuthorizer{
|
||||
Authorizer: auth,
|
||||
Logger: logger.Named("httpauth"),
|
||||
})
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
reqCtx := dbauthz.As(ctx, coderdtest.OwnerSubject())
|
||||
req := httptest.NewRequestWithContext(reqCtx, http.MethodGet, "/api/v2/insights/daus?tz_offset=4", nil)
|
||||
uut.DeploymentDAUs(rw, req)
|
||||
daus, err := codersdk.DecodeResponse[codersdk.DAUsResponse](rw.Result())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.DAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{
|
||||
{Date: "2025-12-01", Amount: 3},
|
||||
{Date: "2025-12-02", Amount: 30},
|
||||
},
|
||||
TZHourOffset: 4,
|
||||
}, daus)
|
||||
}
|
||||
|
||||
func TestUserActivityInsights_SanityCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -648,9 +648,9 @@ func (a RegoAuthorizer) newPartialAuthorizer(ctx context.Context, subject Subjec
|
||||
return pAuth, nil
|
||||
}
|
||||
|
||||
// AuthorizeFilter is a compiled partial query that can be converted to SQL.
|
||||
// SQLAuthorizeFilter is a compiled partial query that can be converted to SQL.
|
||||
// This allows enforcing the policy on the database side in a WHERE clause.
|
||||
type AuthorizeFilter interface {
|
||||
type SQLAuthorizeFilter interface {
|
||||
SQLString() string
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ func ConfigWorkspaces() regosql.ConvertConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func Compile(cfg regosql.ConvertConfig, pa *PartialAuthorizer) (AuthorizeFilter, error) {
|
||||
func Compile(cfg regosql.ConvertConfig, pa *PartialAuthorizer) (SQLAuthorizeFilter, error) {
|
||||
root, err := regosql.ConvertRegoAst(cfg, pa.partialQueries)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert rego ast: %w", err)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package rbac
|
||||
+1
-1
@@ -994,7 +994,7 @@ func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template d
|
||||
func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
template := httpmw.TemplateParam(r)
|
||||
|
||||
api.returnDAUsInternal(rw, r, []uuid.UUID{template.ID})
|
||||
api.insightsAPI.DAUsForTemplates(rw, r, []uuid.UUID{template.ID})
|
||||
}
|
||||
|
||||
// @Summary Get template examples by organization
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/gitsshkey"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
@@ -1435,7 +1436,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Only return orgs the user can read.
|
||||
organizations, err = AuthorizeFilter(api.HTTPAuth, r, policy.ActionRead, organizations)
|
||||
organizations, err = httpauthz.AuthorizationFilter(api.HTTPAuth, r, policy.ActionRead, organizations)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching organizations.",
|
||||
|
||||
@@ -707,3 +707,12 @@ func WithDisableDirectConnections() ClientOption {
|
||||
c.DisableDirectConnections = true
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeResponse[T any](res *http.Response) (T, error) {
|
||||
var result T
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return result, ReadBodyAsError(res)
|
||||
}
|
||||
return result, json.NewDecoder(res.Body).Decode(&result)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func TestEnterpriseEndpointsDocumented(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
swaggerComments, err := coderdtest.ParseSwaggerComments("..", "../../../coderd")
|
||||
swaggerComments, err := coderdtest.ParseSwaggerComments("..", "../../../coderd", "../../../coderd/insightsapi")
|
||||
require.NoError(t, err, "can't parse swagger comments")
|
||||
require.NotEmpty(t, swaggerComments, "swagger comments must be present")
|
||||
|
||||
|
||||
@@ -21,11 +21,12 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpauthz"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -247,7 +248,7 @@ func (api *API) licenses(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
licenses, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, policy.ActionRead, licenses)
|
||||
licenses, err = httpauthz.AuthorizationFilter(api.AGPL.HTTPAuth, r, policy.ActionRead, licenses)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching licenses.",
|
||||
|
||||
Reference in New Issue
Block a user