Skip to content

chore: add x-authz-checks debug header when running in dev mode #16873

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ func New(options *Options) *API {

if options.Authorizer == nil {
options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
if buildinfo.IsDev() {
options.Authorizer = rbac.Recorder(options.Authorizer)
}
Comment on lines +317 to +319
Copy link
Member

Choose a reason for hiding this comment

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

This will actually run on dogfood as well. Is this what we want?

Copy link
Member Author

Choose a reason for hiding this comment

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

yup! makes for a nice quick resource to check

}

if options.AccessControlStore == nil {
Expand Down Expand Up @@ -456,8 +459,14 @@ func New(options *Options) *API {
options.NotificationsEnqueuer = notifications.NewNoopEnqueuer()
}

ctx, cancel := context.WithCancel(context.Background())
r := chi.NewRouter()
// We add this middleware early, to make sure that authorization checks made
// by other middleware get recorded.
if buildinfo.IsDev() {
r.Use(httpmw.RecordAuthzChecks)
}

ctx, cancel := context.WithCancel(context.Background())

// nolint:gocritic // Load deployment ID. This never changes
depID, err := options.Database.GetDeploymentID(dbauthz.AsSystemRestricted(ctx))
Expand Down
21 changes: 21 additions & 0 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/coder/websocket/wsjson"

"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
)
Expand Down Expand Up @@ -198,6 +199,22 @@ func Write(ctx context.Context, rw http.ResponseWriter, status int, response int
_, span := tracing.StartSpan(ctx)
defer span.End()

if rec, ok := rbac.GetAuthzCheckRecorder(ctx); ok {
// If you're here because you saw this header in a response, and you're
// trying to investigate the code, here are a couple of notable things
// for you to know:
// - If any of the checks are `false`, they might not represent the whole
// picture. There could be additional checks that weren't performed,
// because processing stopped after the failure.
// - The checks are recorded by the `authzRecorder` type, which is
// configured on server startup for development and testing builds.
// - If this header is missing from a response, make sure the response is
// being written by calling `httpapi.Write`!
// - An empty x-authz-checks header can be valid! Some requests don't
// require authorization.
rw.Header().Set("x-authz-checks", rec.String())
}

rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(status)

Expand All @@ -213,6 +230,10 @@ func WriteIndent(ctx context.Context, rw http.ResponseWriter, status int, respon
_, span := tracing.StartSpan(ctx)
defer span.End()

if rec, ok := rbac.GetAuthzCheckRecorder(ctx); ok {
rw.Header().Set("x-dbauthz-checks", rec.String())
}

rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(status)

Expand Down
13 changes: 13 additions & 0 deletions coderd/httpmw/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"

"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/rbac"
)

// AsAuthzSystem is a chained handler that temporarily sets the dbauthz context
Expand Down Expand Up @@ -35,3 +36,15 @@ func AsAuthzSystem(mws ...func(http.Handler) http.Handler) func(http.Handler) ht
})
}
}

// RecordAuthzChecks enables recording all of the authorization checks that
// occurred in the processing of a request. This is mostly helpful for debugging
// and understanding what permissions are required for a given action.
//
// Requires using a Recorder Authorizer.
func RecordAuthzChecks(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
r = r.WithContext(rbac.WithAuthzCheckRecorder(r.Context()))
next.ServeHTTP(rw, r)
})
}
104 changes: 101 additions & 3 deletions coderd/rbac/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
Expand All @@ -23,7 +24,9 @@ import (
"github.com/coder/coder/v2/coderd/rbac/regosql"
"github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/util/syncmap"
)

type AuthCall struct {
Expand Down Expand Up @@ -362,11 +365,11 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subject Subject, action p
defer span.End()

err := a.authorize(ctx, subject, action, object)

span.SetAttributes(attribute.Bool("authorized", err == nil))
authorized := err == nil
span.SetAttributes(attribute.Bool("authorized", authorized))

dur := time.Since(start)
if err != nil {
if !authorized {
a.authorizeHist.WithLabelValues("false").Observe(dur.Seconds())
return err
}
Expand Down Expand Up @@ -741,3 +744,98 @@ func rbacTraceAttributes(actor Subject, action policy.Action, objectType string,
attribute.String("object_type", objectType),
)...)
}

type authRecorder struct {
authz Authorizer
}

// Recorder returns an Authorizer that records any authorization checks made
// on the Context provided for the authorization check.
//
// Requires using the RecordAuthzChecks middleware.
func Recorder(authz Authorizer) Authorizer {
return &authRecorder{authz: authz}
}

func (c *authRecorder) Authorize(ctx context.Context, subject Subject, action policy.Action, object Object) error {
err := c.authz.Authorize(ctx, subject, action, object)
authorized := err == nil
recordAuthzCheck(ctx, action, object, authorized)
return err
}

func (c *authRecorder) Prepare(ctx context.Context, subject Subject, action policy.Action, objectType string) (PreparedAuthorized, error) {
return c.authz.Prepare(ctx, subject, action, objectType)
}

type authzCheckRecorderKey struct{}

func WithAuthzCheckRecorder(ctx context.Context) context.Context {
return context.WithValue(ctx, authzCheckRecorderKey{}, ptr.Ref(AuthzCheckRecorder{
checks: syncmap.Map[string, bool]{},
}))
}

type AuthzCheckRecorder struct {
// Checks is a map from preformatted authz check IDs to their authorization
// status (true => authorized, false => not authorized)
checks syncmap.Map[string, bool]
}

func recordAuthzCheck(ctx context.Context, action policy.Action, object Object, authorized bool) {
r, ok := ctx.Value(authzCheckRecorderKey{}).(*AuthzCheckRecorder)
if !ok {
return
}

// We serialize the check using the following syntax
var b strings.Builder
if object.OrgID != "" {
_, err := fmt.Fprintf(&b, "organization:%v::", object.OrgID)
if err != nil {
return
}
}
if object.AnyOrgOwner {
_, err := fmt.Fprint(&b, "organization:any::")
if err != nil {
return
}
}
if object.Owner != "" {
_, err := fmt.Fprintf(&b, "owner:%v::", object.Owner)
if err != nil {
return
}
}
if object.ID != "" {
_, err := fmt.Fprintf(&b, "id:%v::", object.ID)
if err != nil {
return
}
}
_, err := fmt.Fprintf(&b, "%v.%v", object.RBACObject().Type, action)
if err != nil {
return
}

r.checks.Store(b.String(), authorized)
Copy link
Member

Choose a reason for hiding this comment

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

I know this probably shouldn't happen in the real world, but what if the first authz check returns true, and a subsequent authz check for the same subject, action, and object returns false? I think this would be a really good opportunity to catch something like this and make a lot of confused and angry noises.

Copy link
Member Author

Choose a reason for hiding this comment

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

maybe I should just use a slice so that duplicates get recorded

Copy link
Member

Choose a reason for hiding this comment

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

Ooh, a slice would also show you how many times a particular authz check gets done. Maybe a map[string]int to count frequencies? That would make it easier to read?

Copy link
Member Author

Choose a reason for hiding this comment

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

map[string]int doesn't leave room to store the bool. now that I'm thinking about it, a slice is also nice because it guarantees that order is preserved, which is probably a good thing.

Copy link
Member

Choose a reason for hiding this comment

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

That's a good point actually! You can then reason about the order of the checks. 🧠

}

func GetAuthzCheckRecorder(ctx context.Context) (*AuthzCheckRecorder, bool) {
checks, ok := ctx.Value(authzCheckRecorderKey{}).(*AuthzCheckRecorder)
if !ok {
return nil, false
}

return checks, true
}

// String serializes all of the checks recorded, using the following syntax:
func (r *AuthzCheckRecorder) String() string {
checks := make([]string, 0)
for check, result := range r.checks.Seq() {
checks = append(checks, fmt.Sprintf("%v=%v", check, result))
}
return strings.Join(checks, "; ")
}
4 changes: 1 addition & 3 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"slices"

"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"golang.org/x/xerrors"

Expand Down Expand Up @@ -273,8 +272,7 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs
}

render.Status(r, http.StatusOK)
render.JSON(rw, r, codersdk.GetUsersResponse{
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GetUsersResponse{
Users: convertUsers(users, organizationIDsByUserID),
Count: int(userCount),
})
Expand Down
11 changes: 10 additions & 1 deletion coderd/util/syncmap/map.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package syncmap

import "sync"
import (
"iter"
"sync"
)

// Map is a type safe sync.Map
type Map[K, V any] struct {
Expand Down Expand Up @@ -75,3 +78,9 @@ func (m *Map[K, V]) Range(f func(key K, value V) bool) {
return f(key.(K), value.(V))
})
}

func (m *Map[K, V]) Seq() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
m.Range(yield)
}
}
3 changes: 3 additions & 0 deletions enterprise/coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}
if options.Options.Authorizer == nil {
options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
if buildinfo.IsDev() {
options.Authorizer = rbac.Recorder(options.Authorizer)
}
}
if options.ReplicaErrorGracePeriod == 0 {
// This will prevent the error from being shown for a minute
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.15.0
github.com/go-chi/render v1.0.1
github.com/go-jose/go-jose/v4 v4.0.5
github.com/go-logr/logr v1.4.2
github.com/go-playground/validator/v10 v10.26.0
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,6 @@ github.com/go-chi/hostrouter v0.2.0 h1:GwC7TZz8+SlJN/tV/aeJgx4F+mI5+sp+5H1PelQUj
github.com/go-chi/hostrouter v0.2.0/go.mod h1:pJ49vWVmtsKRKZivQx0YMYv4h0aX+Gcn6V23Np9Wf1s=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
Expand Down
Loading