Files
mohammad-hamid 2cd0be3cbd Update authlib version (#107939)
* update authlib version

* add latest versions

* make update-workspace

* typo

* Trigger Build

* Trigger Build
2025-07-11 14:55:52 -04:00

230 lines
7.5 KiB
Go

package resource
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
claims "github.com/grafana/authlib/types"
)
type groupResource map[string]map[string]interface{}
const (
metricsNamespace = "grafana"
metricsSubSystem = "grpc_authz_limited_client"
)
var metOnce sync.Once
type accessMetrics struct {
checkDuration *prometheus.HistogramVec
compileDuration *prometheus.HistogramVec
errorsTotal *prometheus.CounterVec
}
func newMetrics(reg prometheus.Registerer) *accessMetrics {
m := &accessMetrics{
checkDuration: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "check_duration_seconds",
Help: "duration of the access check calls going through the authz service",
}, []string{"group", "resource", "verb", "allowed"}),
compileDuration: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "compile_duration_seconds",
Help: "duration of the access compile calls going through the authz service",
}, []string{"group", "resource", "verb"}),
errorsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "errors_total",
Help: "Number of errors",
}, []string{"group", "resource", "verb"}),
}
if reg != nil {
metOnce.Do(func() {
reg.MustRegister(m.checkDuration)
reg.MustRegister(m.compileDuration)
reg.MustRegister(m.errorsTotal)
})
}
return m
}
// authzLimitedClient is a client that enforces RBAC for the limited number of groups and resources.
// This is a temporary solution until the authz service is fully implemented.
// The authz service will be responsible for enforcing RBAC.
// For now, it makes one call to the authz service for each list items. This is known to be inefficient.
type authzLimitedClient struct {
client claims.AccessClient
// allowlist is a map of group to resources that are compatible with RBAC.
allowlist groupResource
logger *slog.Logger
tracer trace.Tracer
metrics *accessMetrics
}
type AuthzOptions struct {
Tracer trace.Tracer
Registry prometheus.Registerer
}
// NewAuthzLimitedClient creates a new authzLimitedClient.
func NewAuthzLimitedClient(client claims.AccessClient, opts AuthzOptions) claims.AccessClient {
logger := slog.Default().With("logger", "limited-authz-client")
if opts.Tracer == nil {
opts.Tracer = noop.NewTracerProvider().Tracer("limited-authz-client")
}
if opts.Registry == nil {
opts.Registry = prometheus.DefaultRegisterer
}
return &authzLimitedClient{
client: client,
allowlist: groupResource{
"dashboard.grafana.app": map[string]interface{}{"dashboards": nil},
"folder.grafana.app": map[string]interface{}{"folders": nil},
},
logger: logger,
tracer: opts.Tracer,
metrics: newMetrics(opts.Registry),
}
}
// Check implements claims.AccessClient.
func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req claims.CheckRequest) (claims.CheckResponse, error) {
t := time.Now()
ctx, span := c.tracer.Start(ctx, "authzLimitedClient.Check", trace.WithAttributes(
attribute.String("group", req.Group),
attribute.String("resource", req.Resource),
attribute.String("namespace", req.Namespace),
attribute.String("name", req.Name),
attribute.String("verb", req.Verb),
attribute.String("folder", req.Folder),
attribute.Bool("fallback_used", FallbackUsed(ctx)),
))
defer span.End()
if FallbackUsed(ctx) {
if req.Namespace == "" {
// cross namespace queries are not allowed when fallback is used
span.SetAttributes(attribute.Bool("allowed", false))
span.SetStatus(codes.Error, "Namespace empty")
err := fmt.Errorf("namespace empty")
span.RecordError(err)
return claims.CheckResponse{Allowed: false}, err
}
span.SetAttributes(attribute.Bool("allowed", true))
return claims.CheckResponse{Allowed: true}, nil
}
if !claims.NamespaceMatches(id.GetNamespace(), req.Namespace) {
span.SetAttributes(attribute.Bool("allowed", false))
span.SetStatus(codes.Error, "Namespace mismatch")
span.RecordError(claims.ErrNamespaceMismatch)
return claims.CheckResponse{Allowed: false}, claims.ErrNamespaceMismatch
}
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
span.SetAttributes(attribute.Bool("allowed", true))
return claims.CheckResponse{Allowed: true}, nil
}
resp, err := c.client.Check(ctx, id, req)
if err != nil {
c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "error", err, "duration", time.Since(t), "traceid", trace.SpanContextFromContext(ctx).TraceID().String())
c.metrics.errorsTotal.WithLabelValues(req.Group, req.Resource, req.Verb).Inc()
span.SetStatus(codes.Error, fmt.Sprintf("check failed: %v", err))
span.RecordError(err)
return resp, err
}
span.SetAttributes(attribute.Bool("allowed", resp.Allowed))
c.metrics.checkDuration.WithLabelValues(req.Group, req.Resource, req.Verb, fmt.Sprintf("%t", resp.Allowed)).Observe(time.Since(t).Seconds())
return resp, nil
}
// Compile implements claims.AccessClient.
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req claims.ListRequest) (claims.ItemChecker, error) {
t := time.Now()
fallbackUsed := FallbackUsed(ctx)
ctx, span := c.tracer.Start(ctx, "authzLimitedClient.Compile", trace.WithAttributes(
attribute.String("group", req.Group),
attribute.String("resource", req.Resource),
attribute.String("namespace", req.Namespace),
attribute.String("verb", req.Verb),
attribute.Bool("fallback_used", fallbackUsed),
))
defer span.End()
if fallbackUsed {
if req.Namespace == "" {
// cross namespace queries are not allowed when fallback is used
span.SetAttributes(attribute.Bool("allowed", false))
span.SetStatus(codes.Error, "Namespace empty")
err := fmt.Errorf("namespace empty")
span.RecordError(err)
return nil, err
}
return func(name, folder string) bool {
return true
}, nil
}
if !claims.NamespaceMatches(id.GetNamespace(), req.Namespace) {
span.SetAttributes(attribute.Bool("allowed", false))
span.SetStatus(codes.Error, "Namespace mismatch")
span.RecordError(claims.ErrNamespaceMismatch)
return nil, claims.ErrNamespaceMismatch
}
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
return func(name, folder string) bool {
return true
}, nil
}
checker, err := c.client.Compile(ctx, id, req)
if err != nil {
c.logger.Error("Compile", "group", req.Group, "resource", req.Resource, "error", err, "traceid", trace.SpanContextFromContext(ctx).TraceID().String())
c.metrics.errorsTotal.WithLabelValues(req.Group, req.Resource, req.Verb).Inc()
span.SetStatus(codes.Error, fmt.Sprintf("compile failed: %v", err))
span.RecordError(err)
return nil, err
}
c.metrics.compileDuration.WithLabelValues(req.Group, req.Resource, req.Verb).Observe(time.Since(t).Seconds())
return checker, nil
}
func (c authzLimitedClient) IsCompatibleWithRBAC(group, resource string) bool {
if _, ok := c.allowlist[group]; ok {
if _, ok := c.allowlist[group][resource]; ok {
return true
}
}
return false
}
var _ claims.AccessClient = &authzLimitedClient{}
type contextFallbackKey struct{}
func WithFallback(ctx context.Context) context.Context {
return context.WithValue(ctx, contextFallbackKey{}, true)
}
func FallbackUsed(ctx context.Context) bool {
return ctx.Value(contextFallbackKey{}) != nil
}