Compare commits

...

1 Commits

Author SHA1 Message Date
Spike Curtis f02a188f3c chore: refactor /insights API routes for testing 2025-12-01 12:48:23 +00:00
44 changed files with 280 additions and 123 deletions
+4 -4
View File
@@ -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
View File
@@ -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.",
-79
View File
@@ -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
View File
@@ -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.
-4
View File
@@ -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...)
}
+29
View File
@@ -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
}
+14
View File
@@ -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()
}
+1 -1
View File
@@ -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")
+91
View File
@@ -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
}
+27
View File
@@ -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()
+3 -3
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
package rbac
+1 -1
View File
@@ -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
View File
@@ -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.",
+9
View File
@@ -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")
+3 -2
View File
@@ -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.",