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

Skip to content

Commit eb48341

Browse files
authored
chore: More complete tracing for RBAC functions (#5690)
* chore: More complete tracing for RBAC functions * Add input.json as example rbac input for rego cli The input.json is required to play with the rego cli and debug the policy without golang. It is good to have an example to run the commands in the readme.md * Add span events to capture authorize and prepared results * chore: Add prometheus metrics to rbac authorizer
1 parent e821b98 commit eb48341

File tree

12 files changed

+425
-147
lines changed

12 files changed

+425
-147
lines changed

coderd/coderd.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,12 @@ func New(options *Options) *API {
177177
if options.FilesRateLimit == 0 {
178178
options.FilesRateLimit = 12
179179
}
180-
if options.Authorizer == nil {
181-
options.Authorizer = rbac.NewAuthorizer()
182-
}
183180
if options.PrometheusRegistry == nil {
184181
options.PrometheusRegistry = prometheus.NewRegistry()
185182
}
183+
if options.Authorizer == nil {
184+
options.Authorizer = rbac.NewAuthorizer(options.PrometheusRegistry)
185+
}
186186
if options.TailnetCoordinator == nil {
187187
options.TailnetCoordinator = tailnet.NewCoordinator()
188188
}

coderd/coderdtest/authorize.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Obje
565565

566566
// CompileToSQL returns a compiled version of the authorizer that will work for
567567
// in memory databases. This fake version will not work against a SQL database.
568-
func (fakePreparedAuthorizer) CompileToSQL(_ regosql.ConvertConfig) (string, error) {
568+
func (fakePreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) {
569569
return "", xerrors.New("not implemented")
570570
}
571571

coderd/database/modelqueries.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type templateQuerier interface {
3333
}
3434

3535
func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]Template, error) {
36-
authorizedFilter, err := prepared.CompileToSQL(regosql.ConvertConfig{
36+
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
3737
VariableConverter: regosql.TemplateConverter(),
3838
})
3939
if err != nil {
@@ -183,7 +183,7 @@ type workspaceQuerier interface {
183183
// This code is copied from `GetWorkspaces` and adds the authorized filter WHERE
184184
// clause.
185185
func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) {
186-
authorizedFilter, err := prepared.CompileToSQL(rbac.ConfigWithoutACL())
186+
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL())
187187
if err != nil {
188188
return nil, xerrors.Errorf("compile authorized filter: %w", err)
189189
}
@@ -249,7 +249,7 @@ type userQuerier interface {
249249
}
250250

251251
func (q *sqlQuerier) GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
252-
authorizedFilter, err := prepared.CompileToSQL(rbac.ConfigWithoutACL())
252+
authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL())
253253
if err != nil {
254254
return -1, xerrors.Errorf("compile authorized filter: %w", err)
255255
}

coderd/rbac/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,17 @@ Y indicates that the role provides positive permissions, N indicates the role pr
7171
| user | \_ | \_ | Y | Y |
7272
| | \_ | \_ | N | N |
7373
| unauthenticated | \_ | \_ | \_ | N |
74+
75+
# Testing
76+
77+
You can test outside of golang by using the `opa` cli.
78+
79+
**Evaluation**
80+
81+
opa eval --format=pretty 'false' -d policy.rego -i input.json
82+
83+
**Partial Evaluation**
84+
85+
```bash
86+
opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json
87+
```

coderd/rbac/authz.go

Lines changed: 116 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"context"
55
_ "embed"
66
"sync"
7+
"time"
78

89
"github.com/open-policy-agent/opa/rego"
10+
"github.com/prometheus/client_golang/prometheus"
11+
"github.com/prometheus/client_golang/prometheus/promauto"
912
"go.opentelemetry.io/otel/attribute"
1013
"go.opentelemetry.io/otel/trace"
1114
"golang.org/x/xerrors"
@@ -21,27 +24,37 @@ type Authorizer interface {
2124

2225
type PreparedAuthorized interface {
2326
Authorize(ctx context.Context, object Object) error
24-
CompileToSQL(cfg regosql.ConvertConfig) (string, error)
27+
CompileToSQL(ctx context.Context, cfg regosql.ConvertConfig) (string, error)
2528
}
2629

2730
// Filter takes in a list of objects, and will filter the list removing all
2831
// the elements the subject does not have permission for. All objects must be
2932
// of the same type.
33+
//
34+
// Ideally the 'CompileToSQL' is used instead for large sets. This cost scales
35+
// linearly with the number of objects passed in.
3036
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, groups []string, action Action, objects []O) ([]O, error) {
31-
ctx, span := tracing.StartSpan(ctx, trace.WithAttributes(
32-
attribute.String("subject_id", subjID),
33-
attribute.StringSlice("subject_roles", subjRoles),
34-
attribute.Int("num_objects", len(objects)),
35-
))
36-
defer span.End()
37-
3837
if len(objects) == 0 {
3938
// Nothing to filter
4039
return objects, nil
4140
}
4241
objectType := objects[0].RBACObject().Type
4342
filtered := make([]O, 0)
4443

44+
// Start the span after the object type is detected. If we are filtering 0
45+
// objects, then the span is not interesting. It would just add excessive
46+
// 0 time spans that provide no insight.
47+
ctx, span := tracing.StartSpan(ctx,
48+
rbacTraceAttributes(subjRoles, len(groups), scope, action, objectType,
49+
// For filtering, we are only measuring the total time for the entire
50+
// set of objects. This and the 'PrepareByRoleName' span time
51+
// is all that is required to measure the performance of this
52+
// function on a per-object basis.
53+
attribute.Int("num_objects", len(objects)),
54+
),
55+
)
56+
defer span.End()
57+
4558
// Running benchmarks on this function, it is **always** faster to call
4659
// auth.ByRoleName on <10 objects. This is because the overhead of
4760
// 'PrepareByRoleName'. Once we cross 10 objects, then it starts to become
@@ -82,6 +95,9 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub
8295
// RegoAuthorizer will use a prepared rego query for performing authorize()
8396
type RegoAuthorizer struct {
8497
query rego.PreparedEvalQuery
98+
99+
authorizeHist *prometheus.HistogramVec
100+
prepareHist prometheus.Histogram
85101
}
86102

87103
var _ Authorizer = (*RegoAuthorizer)(nil)
@@ -95,7 +111,7 @@ var (
95111
query rego.PreparedEvalQuery
96112
)
97113

98-
func NewAuthorizer() *RegoAuthorizer {
114+
func NewAuthorizer(registry prometheus.Registerer) *RegoAuthorizer {
99115
queryOnce.Do(func() {
100116
var err error
101117
query, err = rego.New(
@@ -106,7 +122,51 @@ func NewAuthorizer() *RegoAuthorizer {
106122
panic(xerrors.Errorf("compile rego: %w", err))
107123
}
108124
})
109-
return &RegoAuthorizer{query: query}
125+
126+
// Register metrics to prometheus.
127+
// These bucket values are based on the average time it takes to run authz
128+
// being around 1ms. Anything under ~2ms is OK and does not need to be
129+
// analyzed any further.
130+
buckets := []float64{
131+
0.0005, // 0.5ms
132+
0.001, // 1ms
133+
0.002, // 2ms
134+
0.003,
135+
0.005,
136+
0.01, // 10ms
137+
0.02,
138+
0.035, // 35ms
139+
0.05,
140+
0.075,
141+
0.1, // 100ms
142+
0.25, // 250ms
143+
0.75, // 750ms
144+
1, // 1s
145+
}
146+
147+
factory := promauto.With(registry)
148+
authorizeHistogram := factory.NewHistogramVec(prometheus.HistogramOpts{
149+
Namespace: "coderd",
150+
Subsystem: "authz",
151+
Name: "authorize_duration_seconds",
152+
Help: "Duration of the 'Authorize' call in seconds. Only counts calls that succeed.",
153+
Buckets: buckets,
154+
}, []string{"allowed"})
155+
156+
prepareHistogram := factory.NewHistogram(prometheus.HistogramOpts{
157+
Namespace: "coderd",
158+
Subsystem: "authz",
159+
Name: "prepare_authorize_duration_seconds",
160+
Help: "Duration of the 'PrepareAuthorize' call in seconds.",
161+
Buckets: buckets,
162+
})
163+
164+
return &RegoAuthorizer{
165+
query: query,
166+
167+
authorizeHist: authorizeHistogram,
168+
prepareHist: prepareHistogram,
169+
}
110170
}
111171

112172
type authSubject struct {
@@ -120,6 +180,18 @@ type authSubject struct {
120180
// This is the function intended to be used outside this package.
121181
// The role is fetched from the builtin map located in memory.
122182
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, object Object) error {
183+
start := time.Now()
184+
ctx, span := tracing.StartSpan(ctx,
185+
trace.WithTimestamp(start), // Reuse the time.Now for metric and trace
186+
rbacTraceAttributes(roleNames, len(groups), scope, action, object.Type,
187+
// For authorizing a single object, this data is useful to know how
188+
// complex our objects are getting.
189+
attribute.Int("object_num_groups", len(object.ACLGroupList)),
190+
attribute.Int("object_num_users", len(object.ACLUserList)),
191+
),
192+
)
193+
defer span.End()
194+
123195
roles, err := RolesByNames(roleNames)
124196
if err != nil {
125197
return err
@@ -131,19 +203,20 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa
131203
}
132204

133205
err = a.Authorize(ctx, subjectID, roles, scopeRole, groups, action, object)
206+
span.AddEvent("authorized", trace.WithAttributes(attribute.Bool("authorized", err == nil)))
207+
dur := time.Since(start)
134208
if err != nil {
209+
a.authorizeHist.WithLabelValues("false").Observe(dur.Seconds())
135210
return err
136211
}
137212

213+
a.authorizeHist.WithLabelValues("true").Observe(dur.Seconds())
138214
return nil
139215
}
140216

141217
// Authorize allows passing in custom Roles.
142218
// This is really helpful for unit testing, as we can create custom roles to exercise edge cases.
143219
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, object Object) error {
144-
ctx, span := tracing.StartSpan(ctx)
145-
defer span.End()
146-
147220
input := map[string]interface{}{
148221
"subject": authSubject{
149222
ID: subjectID,
@@ -166,22 +239,12 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
166239
return nil
167240
}
168241

169-
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
170-
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
171-
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, objectType string) (*PartialAuthorizer, error) {
172-
ctx, span := tracing.StartSpan(ctx)
173-
defer span.End()
174-
175-
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, groups, action, objectType)
176-
if err != nil {
177-
return nil, xerrors.Errorf("new partial authorizer: %w", err)
178-
}
179-
180-
return auth, nil
181-
}
182-
183242
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, objectType string) (PreparedAuthorized, error) {
184-
ctx, span := tracing.StartSpan(ctx)
243+
start := time.Now()
244+
ctx, span := tracing.StartSpan(ctx,
245+
trace.WithTimestamp(start),
246+
rbacTraceAttributes(roleNames, len(groups), scope, action, objectType),
247+
)
185248
defer span.End()
186249

187250
roles, err := RolesByNames(roleNames)
@@ -194,5 +257,29 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
194257
return nil, err
195258
}
196259

197-
return a.Prepare(ctx, subjectID, roles, scopeRole, groups, action, objectType)
260+
prepared, err := a.Prepare(ctx, subjectID, roles, scopeRole, groups, action, objectType)
261+
if err != nil {
262+
return nil, err
263+
}
264+
265+
// Add attributes of the Prepare results. This will help understand the
266+
// complexity of the roles and how it affects the time taken.
267+
span.SetAttributes(
268+
attribute.Int("num_queries", len(prepared.preparedQueries)),
269+
attribute.Bool("always_true", prepared.alwaysTrue),
270+
)
271+
272+
a.prepareHist.Observe(time.Since(start).Seconds())
273+
return prepared, nil
274+
}
275+
276+
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
277+
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
278+
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, objectType string) (*PartialAuthorizer, error) {
279+
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, groups, action, objectType)
280+
if err != nil {
281+
return nil, xerrors.Errorf("new partial authorizer: %w", err)
282+
}
283+
284+
return auth, nil
198285
}

coderd/rbac/authz_internal_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/google/uuid"
10+
"github.com/prometheus/client_golang/prometheus"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
"golang.org/x/xerrors"
@@ -41,7 +42,7 @@ func (w fakeObject) RBACObject() Object {
4142

4243
func TestFilterError(t *testing.T) {
4344
t.Parallel()
44-
auth := NewAuthorizer()
45+
auth := NewAuthorizer(prometheus.NewRegistry())
4546

4647
_, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace})
4748
require.ErrorContains(t, err, "object types must be uniform")
@@ -160,7 +161,7 @@ func TestFilter(t *testing.T) {
160161

161162
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
162163
defer cancel()
163-
auth := NewAuthorizer()
164+
auth := NewAuthorizer(prometheus.NewRegistry())
164165

165166
scope := ScopeAll
166167
if tc.Scope != "" {
@@ -808,7 +809,7 @@ type authTestCase struct {
808809

809810
func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTestCase) {
810811
t.Helper()
811-
authorizer := NewAuthorizer()
812+
authorizer := NewAuthorizer(prometheus.NewRegistry())
812813
for _, cases := range sets {
813814
for i, c := range cases {
814815
c := c

0 commit comments

Comments
 (0)