Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
46 changes: 41 additions & 5 deletions coderd/coderdtest/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
14 changes: 3 additions & 11 deletions coderd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if errors.Is(err, sql.ErrNoRows) {
err = nil
}
Expand Down
39 changes: 39 additions & 0 deletions coderd/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand All @@ -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()

Expand Down
13 changes: 3 additions & 10 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,23 +172,16 @@ 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

// 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
Comment thread
cstyan marked this conversation as resolved.
// 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.",
Expand Down
44 changes: 44 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
cstyan marked this conversation as resolved.
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-10] The exact PrepareCount == 1 assertion holds only while no owner-subject work runs concurrently with the measured request (Komugi).

This is guaranteed today by three facts outside the test: background reconcilers use system subjects, the default coderdtest autobuild ticker never fires, and setup owner requests are drained before Reset(). Verified stable under -count=20 -race. Worth knowing (not changing now): if a future change adds periodic owner-scoped work, or a test in this package enables a firing autobuild ticker that triggers an owner-context prepare, this exact-equality assertion and its twin at templates_test.go:71 will flake at count 2. The tight assertion is the right guard; this is a latent constraint to keep in mind.

🤖


// 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()

Expand Down
Loading