@@ -33,6 +33,15 @@ type AuthCall struct {
33
33
Object Object
34
34
}
35
35
36
+ type scopeDecision struct {
37
+ allow bool
38
+ scopeAllow bool
39
+ scopeAllowList bool
40
+ roleAllow bool
41
+ aclAllow bool
42
+ metricsErr error
43
+ }
44
+
36
45
// hashAuthorizeCall guarantees a unique hash for a given auth call.
37
46
// If two hashes are equal, then the result of a given authorize() call
38
47
// will be the same.
@@ -255,11 +264,15 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subject Subject, a
255
264
256
265
// RegoAuthorizer will use a prepared rego query for performing authorize()
257
266
type RegoAuthorizer struct {
258
- query rego.PreparedEvalQuery
259
- partialQuery rego.PreparedPartialQuery
267
+ query rego.PreparedEvalQuery
268
+ partialQuery rego.PreparedPartialQuery
269
+ scopeMetricsQuery rego.PreparedEvalQuery
260
270
261
- authorizeHist * prometheus.HistogramVec
262
- prepareHist prometheus.Histogram
271
+ authorizeHist * prometheus.HistogramVec
272
+ prepareHist prometheus.Histogram
273
+ scopeDecisionCounter * prometheus.CounterVec
274
+ scopeDecisionDuration * prometheus.HistogramVec
275
+ scopeAllowListMisses * prometheus.CounterVec
263
276
264
277
// strict checking also verifies the inputs to the authorizer. Making sure
265
278
// the action make sense for the input object.
@@ -272,10 +285,11 @@ var (
272
285
// Load the policy from policy.rego in this directory.
273
286
//
274
287
//go:embed policy.rego
275
- regoPolicy string
276
- queryOnce sync.Once
277
- query rego.PreparedEvalQuery
278
- partialQuery rego.PreparedPartialQuery
288
+ regoPolicy string
289
+ queryOnce sync.Once
290
+ query rego.PreparedEvalQuery
291
+ partialQuery rego.PreparedPartialQuery
292
+ scopeMetricsQuery rego.PreparedEvalQuery
279
293
)
280
294
281
295
// NewCachingAuthorizer returns a new RegoAuthorizer that supports context based
@@ -317,6 +331,14 @@ func NewAuthorizer(registry prometheus.Registerer) *RegoAuthorizer {
317
331
if err != nil {
318
332
panic (xerrors .Errorf ("compile partial rego: %w" ,err ))
319
333
}
334
+
335
+ scopeMetricsQuery ,err = rego .New (
336
+ rego .Query ("data.authz.scope_metrics" ),
337
+ rego .Module ("policy.rego" ,regoPolicy ),
338
+ ).PrepareForEval (context .Background ())
339
+ if err != nil {
340
+ panic (xerrors .Errorf ("compile scope metrics rego: %w" ,err ))
341
+ }
320
342
})
321
343
322
344
// Register metrics to prometheus.
@@ -357,12 +379,38 @@ func NewAuthorizer(registry prometheus.Registerer) *RegoAuthorizer {
357
379
Buckets :buckets ,
358
380
})
359
381
360
- return & RegoAuthorizer {
361
- query :query ,
362
- partialQuery :partialQuery ,
382
+ scopeDecisionCounter := factory .NewCounterVec (prometheus.CounterOpts {
383
+ Namespace :"coderd" ,
384
+ Subsystem :"authz" ,
385
+ Name :"scope_enforcement_total" ,
386
+ Help :"Scope evaluation outcomes keyed by decision, scope, and resource." ,
387
+ }, []string {"decision" ,"scope" ,"resource" ,"outcome" })
363
388
364
- authorizeHist :authorizeHistogram ,
365
- prepareHist :prepareHistogram ,
389
+ scopeDecisionDuration := factory .NewHistogramVec (prometheus.HistogramOpts {
390
+ Namespace :"coderd" ,
391
+ Subsystem :"authz" ,
392
+ Name :"scope_enforcement_duration_seconds" ,
393
+ Help :"Duration of scope enforcement decisions in seconds." ,
394
+ Buckets :buckets ,
395
+ }, []string {"decision" ,"scope" ,"resource" ,"outcome" })
396
+
397
+ scopeAllowListMisses := factory .NewCounterVec (prometheus.CounterOpts {
398
+ Namespace :"coderd" ,
399
+ Subsystem :"authz" ,
400
+ Name :"scope_allowlist_miss_total" ,
401
+ Help :"Requests denied because a scope allow-list did not include the resource." ,
402
+ }, []string {"scope" ,"resource" })
403
+
404
+ return & RegoAuthorizer {
405
+ query :query ,
406
+ partialQuery :partialQuery ,
407
+ scopeMetricsQuery :scopeMetricsQuery ,
408
+
409
+ authorizeHist :authorizeHistogram ,
410
+ prepareHist :prepareHistogram ,
411
+ scopeDecisionCounter :scopeDecisionCounter ,
412
+ scopeDecisionDuration :scopeDecisionDuration ,
413
+ scopeAllowListMisses :scopeAllowListMisses ,
366
414
}
367
415
}
368
416
@@ -396,11 +444,12 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subject Subject, action p
396
444
)
397
445
defer span .End ()
398
446
399
- err := a .authorize (ctx ,subject ,action ,object )
447
+ decision , err := a .authorize (ctx ,subject ,action ,object )
400
448
authorized := err == nil
401
449
span .SetAttributes (attribute .Bool ("authorized" ,authorized ))
402
450
403
451
dur := time .Since (start )
452
+ a .observeScopeMetrics (decision ,subject .Scope ,object ,dur )
404
453
if ! authorized {
405
454
a .authorizeHist .WithLabelValues ("false" ).Observe (dur .Seconds ())
406
455
return err
@@ -414,38 +463,182 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subject Subject, action p
414
463
// It is a different function so the exported one can add tracing + metrics.
415
464
// That code tends to clutter up the actual logic, so it's separated out.
416
465
// nolint:revive
417
- func (a RegoAuthorizer )authorize (ctx context.Context ,subject Subject ,action policy.Action ,object Object )error {
466
+ func (a RegoAuthorizer )authorize (ctx context.Context ,subject Subject ,action policy.Action ,object Object ) (scopeDecision ,error ) {
467
+ decision := scopeDecision {}
418
468
if subject .Roles == nil {
419
- return xerrors .Errorf ("subject must have roles" )
469
+ return decision , xerrors .Errorf ("subject must have roles" )
420
470
}
421
471
if subject .Scope == nil {
422
- return xerrors .Errorf ("subject must have a scope" )
472
+ return decision , xerrors .Errorf ("subject must have a scope" )
423
473
}
424
474
425
475
// The caller should use either 1 or the other (or none).
426
476
// Using "AnyOrgOwner" and an OrgID is a contradiction.
427
477
// An empty uuid or a nil uuid means "no org owner".
428
478
if object .AnyOrgOwner && ! (object .OrgID == "" || object .OrgID == "00000000-0000-0000-0000-000000000000" ) {
429
- return xerrors .Errorf ("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive" )
479
+ return decision , xerrors .Errorf ("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive" )
430
480
}
431
481
432
482
astV ,err := regoInputValue (subject ,action ,object )
433
483
if err != nil {
434
- return xerrors .Errorf ("convert input to value: %w" ,err )
484
+ return decision ,xerrors .Errorf ("convert input to value: %w" ,err )
485
+ }
486
+
487
+ metricsResults ,metricsErr := a .scopeMetricsQuery .Eval (ctx ,rego .EvalParsedInput (astV ))
488
+ if metricsErr != nil {
489
+ decision .metricsErr = correctCancelError (metricsErr )
435
490
}
436
491
437
492
results ,err := a .query .Eval (ctx ,rego .EvalParsedInput (astV ))
438
493
if err != nil {
439
494
err = correctCancelError (err )
440
- return xerrors .Errorf ("evaluate rego: %w" ,err )
495
+ return decision ,xerrors .Errorf ("evaluate rego: %w" ,err )
496
+ }
497
+
498
+ decision .allow = results .Allowed ()
499
+ if decision .metricsErr == nil {
500
+ if err := applyScopeMetrics (& decision ,metricsResults );err != nil {
501
+ decision .metricsErr = err
502
+ }
441
503
}
442
504
443
505
if ! results .Allowed () {
444
- return ForbiddenWithInternal (xerrors .Errorf ("policy disallows request" ),subject ,action ,object ,results )
506
+ return decision ,ForbiddenWithInternal (xerrors .Errorf ("policy disallows request" ),subject ,action ,object ,results )
507
+ }
508
+ return decision ,nil
509
+ }
510
+
511
+ func applyScopeMetrics (decision * scopeDecision ,results rego.ResultSet )error {
512
+ if len (results )== 0 {
513
+ return xerrors .Errorf ("scope metrics returned no results" )
514
+ }
515
+ if len (results [0 ].Expressions )== 0 {
516
+ return xerrors .Errorf ("scope metrics returned no expressions" )
517
+ }
518
+ metricsMap ,ok := results [0 ].Expressions [0 ].Value .(map [string ]interface {})
519
+ if ! ok {
520
+ return xerrors .Errorf ("scope metrics expression unexpected type %T" ,results [0 ].Expressions [0 ].Value )
521
+ }
522
+
523
+ boolVal := func (key string ) (bool ,error ) {
524
+ v ,ok := metricsMap [key ]
525
+ if ! ok {
526
+ return false ,xerrors .Errorf ("scope metrics missing key %q" ,key )
527
+ }
528
+ val ,ok := v .(bool )
529
+ if ! ok {
530
+ return false ,xerrors .Errorf ("scope metrics key %q has unexpected type %T" ,key ,v )
531
+ }
532
+ return val ,nil
533
+ }
534
+
535
+ var err error
536
+ if decision .allow ,err = boolVal ("allow" );err != nil {
537
+ return err
538
+ }
539
+ if decision .scopeAllow ,err = boolVal ("scope_allow" );err != nil {
540
+ return err
541
+ }
542
+ if decision .scopeAllowList ,err = boolVal ("scope_allow_list" );err != nil {
543
+ return err
544
+ }
545
+ if decision .roleAllow ,err = boolVal ("role_allow" );err != nil {
546
+ return err
547
+ }
548
+ if decision .aclAllow ,err = boolVal ("acl_allow" );err != nil {
549
+ return err
445
550
}
446
551
return nil
447
552
}
448
553
554
+ var scopeLabelReplacer = strings .NewReplacer (
555
+ " " ,"_" ,
556
+ ":" ,"_" ,
557
+ "*" ,"all" ,
558
+ "-" ,"_" ,
559
+ "." ,"_" ,
560
+ "[" ,"_" ,
561
+ "]" ,"_" ,
562
+ "+" ,"_" ,
563
+ "=" ,"_" ,
564
+ "/" ,"_" ,
565
+ "|" ,"_" ,
566
+ )
567
+
568
+ func sanitizeScopeLabel (scope ExpandableScope )string {
569
+ if scope == nil {
570
+ return "none"
571
+ }
572
+ identifier := scope .Name ()
573
+ raw := identifier .Name
574
+ if raw == "" {
575
+ raw = identifier .String ()
576
+ }
577
+ if raw == "" {
578
+ return "unknown"
579
+ }
580
+ if strings .HasPrefix (raw ,"scopes[" )&& strings .HasSuffix (raw ,"]" ) {
581
+ inner := strings .TrimSuffix (strings .TrimPrefix (raw ,"scopes[" ),"]" )
582
+ if inner == "" {
583
+ return "multi(0)"
584
+ }
585
+ count := strings .Count (inner ,"+" )+ 1
586
+ return fmt .Sprintf ("multi(%d)" ,count )
587
+ }
588
+ sanitized := strings .ToLower (scopeLabelReplacer .Replace (strings .TrimSpace (raw )))
589
+ if sanitized == "" {
590
+ sanitized = "unknown"
591
+ }
592
+ if len (sanitized )> 63 {
593
+ sanitized = sanitized [:63 ]
594
+ }
595
+ return sanitized
596
+ }
597
+
598
+ func classifyScopeDecision (decision scopeDecision )string {
599
+ if decision .metricsErr != nil {
600
+ return "unknown"
601
+ }
602
+ if decision .scopeAllow {
603
+ if decision .allow {
604
+ return "scope_allow"
605
+ }
606
+ if ! decision .roleAllow && ! decision .aclAllow {
607
+ return "role_deny"
608
+ }
609
+ return "other"
610
+ }
611
+ if ! decision .scopeAllowList {
612
+ return "allow_list_deny"
613
+ }
614
+ return "scope_deny"
615
+ }
616
+
617
+ func (a RegoAuthorizer )observeScopeMetrics (decision scopeDecision ,scope ExpandableScope ,object Object ,dur time.Duration ) {
618
+ if a .scopeDecisionCounter == nil || a .scopeDecisionDuration == nil || a .scopeAllowListMisses == nil {
619
+ return
620
+ }
621
+ if decision .metricsErr != nil {
622
+ return
623
+ }
624
+ scopeLabel := sanitizeScopeLabel (scope )
625
+ resource := object .Type
626
+ if resource == "" {
627
+ resource = "unknown"
628
+ }
629
+ outcome := "deny"
630
+ if decision .allow {
631
+ outcome = "allow"
632
+ }
633
+ decisionLabel := classifyScopeDecision (decision )
634
+
635
+ a .scopeDecisionCounter .WithLabelValues (decisionLabel ,scopeLabel ,resource ,outcome ).Inc ()
636
+ a .scopeDecisionDuration .WithLabelValues (decisionLabel ,scopeLabel ,resource ,outcome ).Observe (dur .Seconds ())
637
+ if ! decision .scopeAllowList {
638
+ a .scopeAllowListMisses .WithLabelValues (scopeLabel ,resource ).Inc ()
639
+ }
640
+ }
641
+
449
642
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
450
643
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
451
644
func (a RegoAuthorizer )Prepare (ctx context.Context ,subject Subject ,action policy.Action ,objectType string ) (PreparedAuthorized ,error ) {