Skip to content

Commit 038ad91

Browse files
committed
add x-authz-checks header
1 parent 0b58798 commit 038ad91

File tree

5 files changed

+155
-5
lines changed

5 files changed

+155
-5
lines changed

coderd/coderd.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ func New(options *Options) *API {
315315
if options.Authorizer == nil {
316316
options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry)
317317
}
318+
if buildinfo.IsDev() {
319+
options.Authorizer = rbac.Recorder(options.Authorizer)
320+
}
318321

319322
if options.AccessControlStore == nil {
320323
options.AccessControlStore = &atomic.Pointer[dbauthz.AccessControlStore]{}
@@ -456,8 +459,14 @@ func New(options *Options) *API {
456459
options.NotificationsEnqueuer = notifications.NewNoopEnqueuer()
457460
}
458461

459-
ctx, cancel := context.WithCancel(context.Background())
460462
r := chi.NewRouter()
463+
// We add this middleware early, to make sure that authorization checks made
464+
// by other middleware get recorded.
465+
if buildinfo.IsDev() {
466+
r.Use(httpmw.RecordAuthzChecks)
467+
}
468+
469+
ctx, cancel := context.WithCancel(context.Background())
461470

462471
// nolint:gocritic // Load deployment ID. This never changes
463472
depID, err := options.Database.GetDeploymentID(dbauthz.AsSystemRestricted(ctx))

coderd/httpapi/httpapi.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/coder/websocket/wsjson"
2121

2222
"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
23+
"github.com/coder/coder/v2/coderd/rbac"
2324
"github.com/coder/coder/v2/coderd/tracing"
2425
"github.com/coder/coder/v2/codersdk"
2526
)
@@ -198,6 +199,22 @@ func Write(ctx context.Context, rw http.ResponseWriter, status int, response int
198199
_, span := tracing.StartSpan(ctx)
199200
defer span.End()
200201

202+
if rec, ok := rbac.GetAuthzCheckRecorder(ctx); ok {
203+
// If you're here because you saw this header in a response, and you're
204+
// trying to investigate the code, here are a couple of notable things
205+
// for you to know:
206+
// - If any of the checks are `false`, they might not represent the whole
207+
// picture. There could be additional checks that weren't performed,
208+
// because processing stopped after the failure.
209+
// - The checks are recorded by the `authzRecorder` type, which is
210+
// configured on server startup for development and testing builds.
211+
// - If this header is missing from a response, make sure the response is
212+
// being written by calling `httpapi.Write`!
213+
// - An empty x-authz-checks header can be valid! Some requests don't
214+
// require authorization.
215+
rw.Header().Set("x-authz-checks", rec.String())
216+
}
217+
201218
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
202219
rw.WriteHeader(status)
203220

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

233+
if rec, ok := rbac.GetAuthzCheckRecorder(ctx); ok {
234+
rw.Header().Set("x-dbauthz-checks", rec.String())
235+
}
236+
216237
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
217238
rw.WriteHeader(status)
218239

coderd/httpmw/authz.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/go-chi/chi/v5"
77

88
"github.com/coder/coder/v2/coderd/database/dbauthz"
9+
"github.com/coder/coder/v2/coderd/rbac"
910
)
1011

1112
// AsAuthzSystem is a chained handler that temporarily sets the dbauthz context
@@ -35,3 +36,15 @@ func AsAuthzSystem(mws ...func(http.Handler) http.Handler) func(http.Handler) ht
3536
})
3637
}
3738
}
39+
40+
// RecordAuthzChecks enables recording all of the authorization checks that
41+
// occurred in the processing of a request. This is mostly helpful for debugging
42+
// and understanding what permissions are required for a given action.
43+
//
44+
// Requires using a Recorder Authorizer.
45+
func RecordAuthzChecks(next http.Handler) http.Handler {
46+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
47+
r = r.WithContext(rbac.WithAuthzCheckRecorder(r.Context()))
48+
next.ServeHTTP(rw, r)
49+
})
50+
}

coderd/rbac/authz.go

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
_ "embed"
77
"encoding/json"
88
"errors"
9+
"fmt"
910
"strings"
1011
"sync"
1112
"time"
@@ -23,7 +24,9 @@ import (
2324
"github.com/coder/coder/v2/coderd/rbac/regosql"
2425
"github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes"
2526
"github.com/coder/coder/v2/coderd/tracing"
27+
"github.com/coder/coder/v2/coderd/util/ptr"
2628
"github.com/coder/coder/v2/coderd/util/slice"
29+
"github.com/coder/coder/v2/coderd/util/syncmap"
2730
)
2831

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

364367
err := a.authorize(ctx, subject, action, object)
365-
366-
span.SetAttributes(attribute.Bool("authorized", err == nil))
368+
authorized := err == nil
369+
span.SetAttributes(attribute.Bool("authorized", authorized))
367370

368371
dur := time.Since(start)
369-
if err != nil {
372+
if !authorized {
370373
a.authorizeHist.WithLabelValues("false").Observe(dur.Seconds())
371374
return err
372375
}
@@ -741,3 +744,98 @@ func rbacTraceAttributes(actor Subject, action policy.Action, objectType string,
741744
attribute.String("object_type", objectType),
742745
)...)
743746
}
747+
748+
type authRecorder struct {
749+
authz Authorizer
750+
}
751+
752+
// Recorder returns an Authorizer that records any authorization checks made
753+
// on the Context provided for the authorization check.
754+
//
755+
// Requires using the RecordAuthzChecks middleware.
756+
func Recorder(authz Authorizer) Authorizer {
757+
return &authRecorder{authz: authz}
758+
}
759+
760+
func (c *authRecorder) Authorize(ctx context.Context, subject Subject, action policy.Action, object Object) error {
761+
err := c.authz.Authorize(ctx, subject, action, object)
762+
authorized := err == nil
763+
recordAuthzCheck(ctx, action, object, authorized)
764+
return err
765+
}
766+
767+
func (c *authRecorder) Prepare(ctx context.Context, subject Subject, action policy.Action, objectType string) (PreparedAuthorized, error) {
768+
return c.authz.Prepare(ctx, subject, action, objectType)
769+
}
770+
771+
type authzCheckRecorderKey struct{}
772+
773+
func WithAuthzCheckRecorder(ctx context.Context) context.Context {
774+
return context.WithValue(ctx, authzCheckRecorderKey{}, ptr.Ref(AuthzCheckRecorder{
775+
checks: syncmap.Map[string, bool]{},
776+
}))
777+
}
778+
779+
type AuthzCheckRecorder struct {
780+
// Checks is a map from preformatted authz check IDs to their authorization
781+
// status (true => authorized, false => not authorized)
782+
checks syncmap.Map[string, bool]
783+
}
784+
785+
func recordAuthzCheck(ctx context.Context, action policy.Action, object Object, authorized bool) {
786+
r, ok := ctx.Value(authzCheckRecorderKey{}).(*AuthzCheckRecorder)
787+
if !ok {
788+
return
789+
}
790+
791+
// We serialize the check using the following syntax
792+
var b strings.Builder
793+
if object.OrgID != "" {
794+
_, err := fmt.Fprintf(&b, "organization:%v::", object.OrgID)
795+
if err != nil {
796+
return
797+
}
798+
}
799+
if object.AnyOrgOwner {
800+
_, err := fmt.Fprint(&b, "organization:any::")
801+
if err != nil {
802+
return
803+
}
804+
}
805+
if object.Owner != "" {
806+
_, err := fmt.Fprintf(&b, "owner:%v::", object.Owner)
807+
if err != nil {
808+
return
809+
}
810+
}
811+
if object.ID != "" {
812+
_, err := fmt.Fprintf(&b, "id:%v::", object.ID)
813+
if err != nil {
814+
return
815+
}
816+
}
817+
_, err := fmt.Fprintf(&b, "%v.%v", object.RBACObject().Type, action)
818+
if err != nil {
819+
return
820+
}
821+
822+
r.checks.Store(b.String(), authorized)
823+
}
824+
825+
func GetAuthzCheckRecorder(ctx context.Context) (*AuthzCheckRecorder, bool) {
826+
checks, ok := ctx.Value(authzCheckRecorderKey{}).(*AuthzCheckRecorder)
827+
if !ok {
828+
return nil, false
829+
}
830+
831+
return checks, true
832+
}
833+
834+
// String serializes all of the checks recorded, using the following syntax:
835+
func (r *AuthzCheckRecorder) String() string {
836+
checks := make([]string, 0)
837+
for check, result := range r.checks.Seq() {
838+
checks = append(checks, fmt.Sprintf("%v=%v", check, result))
839+
}
840+
return strings.Join(checks, "; ")
841+
}

coderd/util/syncmap/map.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package syncmap
22

3-
import "sync"
3+
import (
4+
"iter"
5+
"sync"
6+
)
47

58
// Map is a type safe sync.Map
69
type Map[K, V any] struct {
@@ -75,3 +78,9 @@ func (m *Map[K, V]) Range(f func(key K, value V) bool) {
7578
return f(key.(K), value.(V))
7679
})
7780
}
81+
82+
func (m *Map[K, V]) Seq() iter.Seq2[K, V] {
83+
return func(yield func(K, V) bool) {
84+
m.Range(yield)
85+
}
86+
}

0 commit comments

Comments
 (0)