From 4f4047ad4099775287408d23195d4dbb1d15ebda Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 00:39:16 +0000 Subject: [PATCH 1/7] perf(coderd): remove redundant workspaces SQL filter prepare The GET /api/v2/workspaces handler called AuthorizeSQLFilter to build a prepared ResourceWorkspace authorizer and passed it to Database.GetAuthorizedWorkspaces. The dbauthz wrapper ignores that argument and re-prepares its own SQL filter inside GetWorkspaces, so the handler-level prepare was dead work: every request ran OPA partial evaluation twice. Partial evaluation cost scales with the number of organization-scoped roles the subject carries, so for users in many organizations this doubled an already expensive operation (see #21890). Call GetWorkspaces directly, which authorizes the query itself with a single prepare. --- coderd/workspaces.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f4403aee568..7dfe209ad63 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -172,15 +172,6 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { filter.OwnerUsername = "" } - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", - Detail: err.Error(), - }) - return - } - // To show the requester's favorite workspaces first, we pass their userID and compare it to // the workspace owner_id when ordering the rows. filter.RequesterID = apiKey.UserID @@ -188,7 +179,9 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { // We need the technical row to present the correct count on every page. filter.WithSummary = true - workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) + // GetWorkspaces authorizes the query itself, so we don't prepare a SQL + // filter here. + workspaceRows, err := api.Database.GetWorkspaces(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspaces.", From da3d2f858b4ad06c56147b0fc4aefd17a2303143 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 00:40:02 +0000 Subject: [PATCH 2/7] perf(coderd): remove redundant templates SQL filter prepare Same redundant double OPA partial evaluation as the workspaces list: the GET /api/v2/templates handler called AuthorizeSQLFilter to build a prepared ResourceTemplate authorizer, but the dbauthz GetAuthorizedTemplates wrapper ignores it and re-prepares its own SQL filter inside GetTemplatesWithFilter. Call GetTemplatesWithFilter directly, which authorizes the query itself with a single prepare. Like the workspaces change, this halves the OPA partial-evaluation cost of the templates list for users in many organizations (see #21890). --- coderd/templates.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/coderd/templates.go b/coderd/templates.go index 933f46ed2b2..2e3b539d81d 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -577,15 +577,6 @@ func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTem return } - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", - Detail: err.Error(), - }) - return - } - args := filter if mutate != nil { mutate(r, &args) @@ -599,8 +590,9 @@ func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTem } } - // Filter templates based on rbac permissions - templates, err := api.Database.GetAuthorizedTemplates(ctx, args, prepared) + // GetTemplatesWithFilter authorizes the query itself, so we don't + // prepare a SQL filter here. + templates, err := api.Database.GetTemplatesWithFilter(ctx, args) if errors.Is(err, sql.ErrNoRows) { err = nil } From d7e9248d7bb4f9cd16a745cda5199a02b86aba7b Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 00:40:47 +0000 Subject: [PATCH 3/7] test(coderd): guard workspaces/templates against double authz prepare Add a PrepareCountingAuthorizer to coderdtest that counts Authorizer.Prepare calls per (subject, action, objectType), and use it in TestWorkspacesListSingleAuthorizePrepare and TestTemplatesListSingleAuthorizePrepare to assert each list request runs OPA partial evaluation for its resource exactly once. These fail if the redundant handler-level prepare is reintroduced. Add BenchmarkWorkspacesHandler, which drives the real GET /workspaces handler over a mocked database for a user in a growing number of organizations, so the whole authorization path a request takes is measured and its cost scaling with org count is visible (see #21890). --- coderd/coderdtest/authorize.go | 57 +++++++ coderd/templates_test.go | 39 +++++ coderd/workspaces_bench_internal_test.go | 202 +++++++++++++++++++++++ coderd/workspaces_test.go | 39 +++++ 4 files changed, 337 insertions(+) create mode 100644 coderd/workspaces_bench_internal_test.go diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 42146f94098..c5a7ff4bb1e 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -501,3 +501,60 @@ func AccessControlStorePointer() *atomic.Pointer[dbauthz.AccessControlStore] { acs.Store(&tacs) return acs } + +// PrepareCountingAuthorizer wraps an Authorizer and counts calls to Prepare, +// keyed by (subjectID, action, objectType). Tests use it to assert that a +// request runs OPA partial evaluation exactly once for a given resource, +// guarding against redundant Prepare calls (for example, a handler preparing a +// SQL filter that the dbauthz layer then re-prepares). Authorize is delegated +// unchanged. +// +// Unlike RecordingAuthorizer, which records Authorize calls, this records +// Prepare calls; the built-in caching authorizer does not dedupe Prepare, so +// the counts reflect every partial evaluation performed. Counts are keyed by +// subject ID so a test can isolate the prepares made on behalf of a specific +// user and ignore background work performed under system subjects. +type PrepareCountingAuthorizer struct { + rbac.Authorizer + + mu sync.Mutex + counts map[string]int +} + +var _ rbac.Authorizer = (*PrepareCountingAuthorizer)(nil) + +// NewPrepareCountingAuthorizer wraps the given Authorizer. Pass the same kind of +// authorizer coderdtest uses by default (rbac.NewStrictCachingAuthorizer) so the +// authorization behavior is unchanged. +func NewPrepareCountingAuthorizer(wrapped rbac.Authorizer) *PrepareCountingAuthorizer { + return &PrepareCountingAuthorizer{ + Authorizer: wrapped, + counts: make(map[string]int), + } +} + +func prepareCountKey(subjectID string, action policy.Action, objectType string) string { + return subjectID + "|" + string(action) + "|" + objectType +} + +func (a *PrepareCountingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) { + a.mu.Lock() + a.counts[prepareCountKey(subject.ID, action, objectType)]++ + a.mu.Unlock() + return a.Authorizer.Prepare(ctx, subject, action, objectType) +} + +// PrepareCount returns the number of Prepare calls recorded for the given +// subject, action, and object type. +func (a *PrepareCountingAuthorizer) PrepareCount(subjectID string, action policy.Action, objectType string) int { + a.mu.Lock() + defer a.mu.Unlock() + return a.counts[prepareCountKey(subjectID, action, objectType)] +} + +// Reset clears all recorded Prepare counts. +func (a *PrepareCountingAuthorizer) Reset() { + a.mu.Lock() + defer a.mu.Unlock() + a.counts = make(map[string]int) +} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 8c23c0e596b..60465dbeda0 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +25,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -32,6 +34,43 @@ import ( "github.com/coder/coder/v2/testutil" ) +// TestTemplatesListSingleAuthorizePrepare guards against reintroducing the +// redundant OPA partial evaluation the GET /api/v2/templates handler used to +// perform. The handler called AuthorizeSQLFilter to build a prepared +// ResourceTemplate authorizer, but the dbauthz GetAuthorizedTemplates wrapper +// ignored it and re-prepared inside GetTemplatesWithFilter, so every request +// ran partial evaluation twice. Partial-evaluation cost scales with the +// number of organization-scoped roles the subject carries (see #21890), so the +// duplicate prepare doubled an already expensive operation. A single list +// request must prepare the ResourceTemplate authorizer exactly once. +func TestTemplatesListSingleAuthorizePrepare(t *testing.T) { + t.Parallel() + + authz := coderdtest.NewPrepareCountingAuthorizer(rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Authorizer: authz, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Reset immediately before the measured request so setup prepares (template + // creation, version jobs) are excluded. Counts are keyed by subject ID, so + // background work under system subjects is ignored. + authz.Reset() + templates, err := client.Templates(ctx, codersdk.TemplateFilter{}) + require.NoError(t, err) + require.Len(t, templates, 1) + + count := authz.PrepareCount(owner.UserID.String(), policy.ActionRead, rbac.ResourceTemplate.Type) + require.Equal(t, 1, count, + "GET /templates must prepare the ResourceTemplate authorizer exactly once; a higher count means a redundant partial evaluation was reintroduced") +} + func TestTemplate(t *testing.T) { t.Parallel() diff --git a/coderd/workspaces_bench_internal_test.go b/coderd/workspaces_bench_internal_test.go new file mode 100644 index 00000000000..1c40cf552a9 --- /dev/null +++ b/coderd/workspaces_bench_internal_test.go @@ -0,0 +1,202 @@ +package coderd + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "go.uber.org/mock/gomock" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/apikey" + "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/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" +) + +// BenchmarkWorkspacesHandler measures the authorization cost of the real +// GET /api/v2/workspaces HTTP handler (api.workspaces) for a user that belongs +// to a growing number of organizations. +// +// It exercises the full handler (api.workspaces) rather than +// dbauthz.GetWorkspaces so that the whole authorization path a request takes, +// including the handler-level HTTPAuth.AuthorizeSQLFilter call, is measured. +// The database is mocked so the benchmark isolates the authorization work. +// +// Partial evaluation cost is driven by the subject, not the object type: OPA +// expands the policy against the subject's N org-scoped roles, so it scales +// with org count (see #21890). +func BenchmarkWorkspacesHandler(b *testing.B) { + orgCounts := []int{1, 10, 50, 100, 200} + + for _, n := range orgCounts { + b.Run(fmt.Sprintf("orgs=%d", n), func(b *testing.B) { + ctrl := gomock.NewController(b) + mockDB := dbmock.NewMockStore(ctrl) + mockDB.EXPECT().Wrappers().Return([]string{}).AnyTimes() + + userID := uuid.New() + + // The subject is built by the real ExtractAPIKey middleware path + // (below), which resolves the user's roles via + // GetAuthorizationUserRoles -> rolestore.Expand -> CustomRoles. Set + // up the two lookups that path makes so the subject matches what a + // real request produces (fully expanded roles, cached AST value). + roleNames := make([]string, 0, n+1) + roleNames = append(roleNames, rbac.RoleMember().String()) + for range n { + orgRole := rbac.RoleIdentifier{Name: rbac.RoleOrgMember(), OrganizationID: uuid.New()} + roleNames = append(roleNames, orgRole.String()) + } + mockDB.EXPECT(). + GetAuthorizationUserRoles(gomock.Any(), userID). + Return(database.GetAuthorizationUserRolesRow{ + ID: userID, + Username: "bench", + Status: database.UserStatusActive, + Email: "bench@coder.com", + Roles: roleNames, + }, nil). + AnyTimes() + + // CustomRoles resolves the organization-member system role for each + // org the user belongs to, the same way the real query does with + // IncludeSystemRoles. + memberPerms := rbac.OrgMemberPermissions(rbac.OrgSettings{}) + mockDB.EXPECT(). + CustomRoles(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { + out := make([]database.CustomRole, 0, len(arg.LookupRoles)) + for _, pair := range arg.LookupRoles { + out = append(out, database.CustomRole{ + Name: pair.Name, + OrganizationID: uuid.NullUUID{UUID: pair.OrganizationID, Valid: pair.OrganizationID != uuid.Nil}, + IsSystem: true, + OrgPermissions: rolestore.ConvertPermissionsToDB(memberPerms.Org), + MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms.Member), + }) + } + return out, nil + }). + AnyTimes() + + // Generate a real token so ExtractAPIKey's hash validation passes, + // and answer the key lookups it makes. These run once (during the + // capture below), not in the measured loop. + insertParams, token, err := apikey.Generate(apikey.CreateParams{ + UserID: userID, + LoginType: database.LoginTypeToken, + DefaultLifetime: time.Hour, + }) + if err != nil { + b.Fatal(err) + } + dbKey := database.APIKey{ + ID: insertParams.ID, + HashedSecret: insertParams.HashedSecret, + UserID: userID, + ExpiresAt: insertParams.ExpiresAt, + LastUsed: insertParams.LastUsed, + LoginType: database.LoginTypeToken, + LifetimeSeconds: insertParams.LifetimeSeconds, + Scopes: insertParams.Scopes, + AllowList: insertParams.AllowList, + } + mockDB.EXPECT().GetAPIKeyByID(gomock.Any(), insertParams.ID).Return(dbKey, nil).AnyTimes() + mockDB.EXPECT().UpdateAPIKeyByID(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockDB.EXPECT().UpdateUserLastSeenAt(gomock.Any(), gomock.Any()).Return(database.User{ID: userID}, nil).AnyTimes() + + // Return a single technical summary row so the handler takes its + // early-return path (workspaces.go: len(workspaceRows) == 1), + // skipping enrichment. The measured cost is the per-request Prepare, + // not row conversion. Mirror sqlQuerier.GetAuthorizedWorkspaces by + // compiling the prepared authorizer to SQL, so CompileToSQL is + // captured too. + summaryRow := []database.GetWorkspacesRow{{}} + mockDB.EXPECT(). + GetAuthorizedWorkspaces(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _ database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { + if _, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()); err != nil { + return nil, err + } + return summaryRow, nil + }). + AnyTimes() + + // Use a non-caching authorizer so this measures the cold cost. + authorizer := rbac.NewAuthorizer(prometheus.NewRegistry()) + logger := slog.Make() + + acs := &atomic.Pointer[dbauthz.AccessControlStore]{} + var tacs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} + acs.Store(&tacs) + authzDB := dbauthz.New(mockDB, authorizer, logger, acs) + + api := &API{ + Options: &Options{ + Database: authzDB, + Logger: logger, + Authorizer: authorizer, + AgentInactiveDisconnectTimeout: time.Minute, + }, + // HTTPAuth backs the handler's AuthorizeSQLFilter call. + HTTPAuth: &HTTPAuthorizer{Authorizer: authorizer, Logger: logger}, + } + + // Capture an authenticated request context once by running the real + // ExtractAPIKey middleware. The captured context carries both the + // apiKey value (read by httpmw.APIKey in the handler) and the + // dbauthz actor (read by AuthorizeSQLFilter), so per-iteration cost + // excludes subject construction and isolates the handler work. + authedCtx := captureAuthedContext(b, mockDB, logger, token) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil).WithContext(authedCtx) + api.workspaces(rec, req) + if rec.Code != http.StatusOK { + b.Fatalf("unexpected status %d: %s", rec.Code, rec.Body.String()) + } + } + }) + } +} + +// captureAuthedContext runs the real ExtractAPIKey middleware a single time and +// returns the resulting request context, which carries the apiKey value and the +// dbauthz actor. There is no exported setter for the (unexported) apiKey context +// key, so running the middleware is the supported way to build a valid +// authenticated context. +func captureAuthedContext(b *testing.B, db database.Store, logger slog.Logger, token string) context.Context { + b.Helper() + + var captured context.Context + mw := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + Logger: logger, + // Bypass header/cookie parsing; return the generated token directly. + SessionTokenFunc: func(*http.Request) string { return token }, + }) + handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + captured = r.Context() + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil) + handler.ServeHTTP(rec, req) + if captured == nil { + b.Fatalf("failed to authenticate benchmark request: status %d: %s", rec.Code, rec.Body.String()) + } + return captured +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2c4627d3662..5d81a4f1a8b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -53,6 +53,45 @@ import ( "github.com/coder/terraform-provider-coder/v2/provider" ) +// TestWorkspacesListSingleAuthorizePrepare guards against reintroducing the +// redundant OPA partial evaluation the GET /api/v2/workspaces handler used to +// perform. The handler called AuthorizeSQLFilter to build a prepared +// ResourceWorkspace authorizer, but the dbauthz GetAuthorizedWorkspaces wrapper +// ignored it and re-prepared inside GetWorkspaces, so every request ran partial +// evaluation twice. Partial-evaluation cost scales with the number of +// organization-scoped roles the subject carries (see #21890), so the duplicate +// prepare doubled an already expensive operation. A single list request must +// prepare the ResourceWorkspace authorizer exactly once. +func TestWorkspacesListSingleAuthorizePrepare(t *testing.T) { + t.Parallel() + + authz := coderdtest.NewPrepareCountingAuthorizer(rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Authorizer: authz, + }) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Reset immediately before the measured request so setup prepares (template + // and workspace creation) are excluded. Counts are keyed by subject ID, so + // background work under system subjects is ignored. + authz.Reset() + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + + count := authz.PrepareCount(owner.UserID.String(), policy.ActionRead, rbac.ResourceWorkspace.Type) + require.Equal(t, 1, count, + "GET /workspaces must prepare the ResourceWorkspace authorizer exactly once; a higher count means a redundant partial evaluation was reintroduced") +} + func TestWorkspace(t *testing.T) { t.Parallel() From c55e6728c83c239050c9535227fbf691da9c8001 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 03:20:02 +0000 Subject: [PATCH 4/7] test(coderd): note single-prepare count assumes serial owner request --- coderd/workspaces_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5d81a4f1a8b..07398a9b19b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -87,6 +87,9 @@ func TestWorkspacesListSingleAuthorizePrepare(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 1) + // The exact count of 1 relies on this being the only request issued under the + // owner subject between the reset and this assertion, which holds because the + // test makes a single serial call. count := authz.PrepareCount(owner.UserID.String(), policy.ActionRead, rbac.ResourceWorkspace.Type) require.Equal(t, 1, count, "GET /workspaces must prepare the ResourceWorkspace authorizer exactly once; a higher count means a redundant partial evaluation was reintroduced") From db30bea573c84d896b83e10d04d072ff543b3ca1 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 03:21:33 +0000 Subject: [PATCH 5/7] test(coderd): seed workspace via dbfake in single-prepare guard --- coderd/workspaces_test.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 07398a9b19b..074186b8170 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -66,22 +66,24 @@ func TestWorkspacesListSingleAuthorizePrepare(t *testing.T) { t.Parallel() authz := coderdtest.NewPrepareCountingAuthorizer(rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())) - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - Authorizer: authz, + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + Authorizer: authz, }) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Seed one workspace directly in the database. The authorization path the + // handler takes does not depend on how the workspace was built, so dbfake + // avoids the cost of a provisioner and real build. + dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + }).Do() ctx := testutil.Context(t, testutil.WaitLong) - // Reset immediately before the measured request so setup prepares (template - // and workspace creation) are excluded. Counts are keyed by subject ID, so - // background work under system subjects is ignored. + // Reset immediately before the measured request so setup prepares are + // excluded. Counts are keyed by subject ID, so background work under system + // subjects is ignored. authz.Reset() res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) From 9ea186f69068f78062028f9c067783f046dee671 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 18:17:23 +0000 Subject: [PATCH 6/7] test(coderd): fold prepare counting into RecordingAuthorizer Replace the standalone PrepareCountingAuthorizer with prepare counting on the existing RecordingAuthorizer. Prepare calls are recorded into a separate Prepared slice (kept out of Called so existing Authorize-call assertions are unaffected), and PrepareCount/Reset expose and clear them. The single-prepare guard tests inject a plain RecordingAuthorizer instead of a bespoke type. --- coderd/coderdtest/authorize.go | 103 +++++++++++++-------------------- coderd/templates_test.go | 2 +- coderd/workspaces_test.go | 2 +- 3 files changed, 43 insertions(+), 64 deletions(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index c5a7ff4bb1e..b7b8be4c74d 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -151,14 +151,23 @@ type AuthCall struct { callers []string } +// PrepareCall is a recorded call to Authorizer.Prepare. Unlike AuthCall it has +// no rbac.Object, only the object type string that Prepare receives. +type PrepareCall struct { + Actor rbac.Subject + Action policy.Action + ObjectType string +} + var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) // RecordingAuthorizer wraps any rbac.Authorizer and records all Authorize() // calls made. This is useful for testing as these calls can later be asserted. type RecordingAuthorizer struct { sync.RWMutex - Called []AuthCall - Wrapped rbac.Authorizer + Called []AuthCall + Prepared []PrepareCall + Wrapped rbac.Authorizer } type ActionObjectPair struct { @@ -209,6 +218,22 @@ func (r *RecordingAuthorizer) AllCalls(actor *rbac.Subject) []AuthCall { return called } +// PrepareCount returns how many Prepare calls were recorded for the given +// subject, action, and object type. Counts are keyed by subject ID so a test +// can isolate the prepares made on behalf of a specific user and ignore +// background work performed under system subjects. +func (r *RecordingAuthorizer) PrepareCount(subjectID string, action policy.Action, objectType string) int { + r.RLock() + defer r.RUnlock() + n := 0 + for _, p := range r.Prepared { + if p.Actor.ID == subjectID && p.Action == action && p.ObjectType == objectType { + n++ + } + } + return n +} + // AssertOutOfOrder asserts that the given actor performed the given action // on the given objects. It does not care about the order of the calls. // When marking authz calls as asserted, it will mark the first matching @@ -305,11 +330,10 @@ func (r *RecordingAuthorizer) Authorize(ctx context.Context, subject rbac.Subjec } func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) { - r.RLock() - defer r.RUnlock() if r.Wrapped == nil { panic("Developer error: RecordingAuthorizer.Wrapped is nil") } + r.recordPrepare(subject, action, objectType) prep, err := r.Wrapped.Prepare(ctx, subject, action, objectType) if err != nil { @@ -323,11 +347,23 @@ func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, }, nil } -// Reset clears the recorded Authorize() calls. +// recordPrepare is the internal method that records the Prepare() call. +func (r *RecordingAuthorizer) recordPrepare(subject rbac.Subject, action policy.Action, objectType string) { + r.Lock() + defer r.Unlock() + r.Prepared = append(r.Prepared, PrepareCall{ + Actor: subject, + Action: action, + ObjectType: objectType, + }) +} + +// Reset clears the recorded Authorize() and Prepare() calls. func (r *RecordingAuthorizer) Reset() { r.Lock() defer r.Unlock() r.Called = nil + r.Prepared = nil } // PreparedRecorder is the prepared version of the RecordingAuthorizer. @@ -501,60 +537,3 @@ func AccessControlStorePointer() *atomic.Pointer[dbauthz.AccessControlStore] { acs.Store(&tacs) return acs } - -// PrepareCountingAuthorizer wraps an Authorizer and counts calls to Prepare, -// keyed by (subjectID, action, objectType). Tests use it to assert that a -// request runs OPA partial evaluation exactly once for a given resource, -// guarding against redundant Prepare calls (for example, a handler preparing a -// SQL filter that the dbauthz layer then re-prepares). Authorize is delegated -// unchanged. -// -// Unlike RecordingAuthorizer, which records Authorize calls, this records -// Prepare calls; the built-in caching authorizer does not dedupe Prepare, so -// the counts reflect every partial evaluation performed. Counts are keyed by -// subject ID so a test can isolate the prepares made on behalf of a specific -// user and ignore background work performed under system subjects. -type PrepareCountingAuthorizer struct { - rbac.Authorizer - - mu sync.Mutex - counts map[string]int -} - -var _ rbac.Authorizer = (*PrepareCountingAuthorizer)(nil) - -// NewPrepareCountingAuthorizer wraps the given Authorizer. Pass the same kind of -// authorizer coderdtest uses by default (rbac.NewStrictCachingAuthorizer) so the -// authorization behavior is unchanged. -func NewPrepareCountingAuthorizer(wrapped rbac.Authorizer) *PrepareCountingAuthorizer { - return &PrepareCountingAuthorizer{ - Authorizer: wrapped, - counts: make(map[string]int), - } -} - -func prepareCountKey(subjectID string, action policy.Action, objectType string) string { - return subjectID + "|" + string(action) + "|" + objectType -} - -func (a *PrepareCountingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) { - a.mu.Lock() - a.counts[prepareCountKey(subject.ID, action, objectType)]++ - a.mu.Unlock() - return a.Authorizer.Prepare(ctx, subject, action, objectType) -} - -// PrepareCount returns the number of Prepare calls recorded for the given -// subject, action, and object type. -func (a *PrepareCountingAuthorizer) PrepareCount(subjectID string, action policy.Action, objectType string) int { - a.mu.Lock() - defer a.mu.Unlock() - return a.counts[prepareCountKey(subjectID, action, objectType)] -} - -// Reset clears all recorded Prepare counts. -func (a *PrepareCountingAuthorizer) Reset() { - a.mu.Lock() - defer a.mu.Unlock() - a.counts = make(map[string]int) -} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 60465dbeda0..dbe82329b8d 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -46,7 +46,7 @@ import ( func TestTemplatesListSingleAuthorizePrepare(t *testing.T) { t.Parallel() - authz := coderdtest.NewPrepareCountingAuthorizer(rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())) + authz := &coderdtest.RecordingAuthorizer{Wrapped: rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())} client := coderdtest.New(t, &coderdtest.Options{ IncludeProvisionerDaemon: true, Authorizer: authz, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 074186b8170..8749029504e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -65,7 +65,7 @@ import ( func TestWorkspacesListSingleAuthorizePrepare(t *testing.T) { t.Parallel() - authz := coderdtest.NewPrepareCountingAuthorizer(rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())) + authz := &coderdtest.RecordingAuthorizer{Wrapped: rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())} client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ Authorizer: authz, }) From 9fb8a13ac220bd597c8a635dc3fcaf95926a5507 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Wed, 15 Jul 2026 20:00:32 +0000 Subject: [PATCH 7/7] test(coderd): remove workspaces handler benchmark BenchmarkWorkspacesHandler served its purpose: it quantified the double to single OPA prepare reduction on the list handler while that change was under review. The prepare-count guard tests (TestWorkspacesListSingleAuthorizePrepare, TestTemplatesListSingleAuthorizePrepare) provide the lasting regression protection, so the benchmark and its capture-once helper are no longer needed. This commit can be referenced if the handler-over-mock benchmark is ever needed again. --- coderd/workspaces_bench_internal_test.go | 202 ----------------------- 1 file changed, 202 deletions(-) delete mode 100644 coderd/workspaces_bench_internal_test.go diff --git a/coderd/workspaces_bench_internal_test.go b/coderd/workspaces_bench_internal_test.go deleted file mode 100644 index 1c40cf552a9..00000000000 --- a/coderd/workspaces_bench_internal_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package coderd - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - "go.uber.org/mock/gomock" - - "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/apikey" - "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/httpmw" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/rolestore" -) - -// BenchmarkWorkspacesHandler measures the authorization cost of the real -// GET /api/v2/workspaces HTTP handler (api.workspaces) for a user that belongs -// to a growing number of organizations. -// -// It exercises the full handler (api.workspaces) rather than -// dbauthz.GetWorkspaces so that the whole authorization path a request takes, -// including the handler-level HTTPAuth.AuthorizeSQLFilter call, is measured. -// The database is mocked so the benchmark isolates the authorization work. -// -// Partial evaluation cost is driven by the subject, not the object type: OPA -// expands the policy against the subject's N org-scoped roles, so it scales -// with org count (see #21890). -func BenchmarkWorkspacesHandler(b *testing.B) { - orgCounts := []int{1, 10, 50, 100, 200} - - for _, n := range orgCounts { - b.Run(fmt.Sprintf("orgs=%d", n), func(b *testing.B) { - ctrl := gomock.NewController(b) - mockDB := dbmock.NewMockStore(ctrl) - mockDB.EXPECT().Wrappers().Return([]string{}).AnyTimes() - - userID := uuid.New() - - // The subject is built by the real ExtractAPIKey middleware path - // (below), which resolves the user's roles via - // GetAuthorizationUserRoles -> rolestore.Expand -> CustomRoles. Set - // up the two lookups that path makes so the subject matches what a - // real request produces (fully expanded roles, cached AST value). - roleNames := make([]string, 0, n+1) - roleNames = append(roleNames, rbac.RoleMember().String()) - for range n { - orgRole := rbac.RoleIdentifier{Name: rbac.RoleOrgMember(), OrganizationID: uuid.New()} - roleNames = append(roleNames, orgRole.String()) - } - mockDB.EXPECT(). - GetAuthorizationUserRoles(gomock.Any(), userID). - Return(database.GetAuthorizationUserRolesRow{ - ID: userID, - Username: "bench", - Status: database.UserStatusActive, - Email: "bench@coder.com", - Roles: roleNames, - }, nil). - AnyTimes() - - // CustomRoles resolves the organization-member system role for each - // org the user belongs to, the same way the real query does with - // IncludeSystemRoles. - memberPerms := rbac.OrgMemberPermissions(rbac.OrgSettings{}) - mockDB.EXPECT(). - CustomRoles(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { - out := make([]database.CustomRole, 0, len(arg.LookupRoles)) - for _, pair := range arg.LookupRoles { - out = append(out, database.CustomRole{ - Name: pair.Name, - OrganizationID: uuid.NullUUID{UUID: pair.OrganizationID, Valid: pair.OrganizationID != uuid.Nil}, - IsSystem: true, - OrgPermissions: rolestore.ConvertPermissionsToDB(memberPerms.Org), - MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms.Member), - }) - } - return out, nil - }). - AnyTimes() - - // Generate a real token so ExtractAPIKey's hash validation passes, - // and answer the key lookups it makes. These run once (during the - // capture below), not in the measured loop. - insertParams, token, err := apikey.Generate(apikey.CreateParams{ - UserID: userID, - LoginType: database.LoginTypeToken, - DefaultLifetime: time.Hour, - }) - if err != nil { - b.Fatal(err) - } - dbKey := database.APIKey{ - ID: insertParams.ID, - HashedSecret: insertParams.HashedSecret, - UserID: userID, - ExpiresAt: insertParams.ExpiresAt, - LastUsed: insertParams.LastUsed, - LoginType: database.LoginTypeToken, - LifetimeSeconds: insertParams.LifetimeSeconds, - Scopes: insertParams.Scopes, - AllowList: insertParams.AllowList, - } - mockDB.EXPECT().GetAPIKeyByID(gomock.Any(), insertParams.ID).Return(dbKey, nil).AnyTimes() - mockDB.EXPECT().UpdateAPIKeyByID(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - mockDB.EXPECT().UpdateUserLastSeenAt(gomock.Any(), gomock.Any()).Return(database.User{ID: userID}, nil).AnyTimes() - - // Return a single technical summary row so the handler takes its - // early-return path (workspaces.go: len(workspaceRows) == 1), - // skipping enrichment. The measured cost is the per-request Prepare, - // not row conversion. Mirror sqlQuerier.GetAuthorizedWorkspaces by - // compiling the prepared authorizer to SQL, so CompileToSQL is - // captured too. - summaryRow := []database.GetWorkspacesRow{{}} - mockDB.EXPECT(). - GetAuthorizedWorkspaces(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, _ database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { - if _, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()); err != nil { - return nil, err - } - return summaryRow, nil - }). - AnyTimes() - - // Use a non-caching authorizer so this measures the cold cost. - authorizer := rbac.NewAuthorizer(prometheus.NewRegistry()) - logger := slog.Make() - - acs := &atomic.Pointer[dbauthz.AccessControlStore]{} - var tacs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} - acs.Store(&tacs) - authzDB := dbauthz.New(mockDB, authorizer, logger, acs) - - api := &API{ - Options: &Options{ - Database: authzDB, - Logger: logger, - Authorizer: authorizer, - AgentInactiveDisconnectTimeout: time.Minute, - }, - // HTTPAuth backs the handler's AuthorizeSQLFilter call. - HTTPAuth: &HTTPAuthorizer{Authorizer: authorizer, Logger: logger}, - } - - // Capture an authenticated request context once by running the real - // ExtractAPIKey middleware. The captured context carries both the - // apiKey value (read by httpmw.APIKey in the handler) and the - // dbauthz actor (read by AuthorizeSQLFilter), so per-iteration cost - // excludes subject construction and isolates the handler work. - authedCtx := captureAuthedContext(b, mockDB, logger, token) - - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil).WithContext(authedCtx) - api.workspaces(rec, req) - if rec.Code != http.StatusOK { - b.Fatalf("unexpected status %d: %s", rec.Code, rec.Body.String()) - } - } - }) - } -} - -// captureAuthedContext runs the real ExtractAPIKey middleware a single time and -// returns the resulting request context, which carries the apiKey value and the -// dbauthz actor. There is no exported setter for the (unexported) apiKey context -// key, so running the middleware is the supported way to build a valid -// authenticated context. -func captureAuthedContext(b *testing.B, db database.Store, logger slog.Logger, token string) context.Context { - b.Helper() - - var captured context.Context - mw := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ - DB: db, - Logger: logger, - // Bypass header/cookie parsing; return the generated token directly. - SessionTokenFunc: func(*http.Request) string { return token }, - }) - handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - captured = r.Context() - })) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v2/workspaces", nil) - handler.ServeHTTP(rec, req) - if captured == nil { - b.Fatalf("failed to authenticate benchmark request: status %d: %s", rec.Code, rec.Body.String()) - } - return captured -}