diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 42146f94098..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. 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 } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 8c23c0e596b..dbe82329b8d 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.RecordingAuthorizer{Wrapped: 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.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.", diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2c4627d3662..8749029504e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -53,6 +53,50 @@ 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.RecordingAuthorizer{Wrapped: rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())} + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + Authorizer: authz, + }) + owner := coderdtest.CreateFirstUser(t, client) + + // 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 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) + + // 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") +} + func TestWorkspace(t *testing.T) { t.Parallel()