Zanzana: Evaluate permissions alongside with RBAC engine (#90064)
* Zanzana: Evaluate permissions if feature flag enabled * Fix tests * adjust logs * fix spelling * remove unused * only evaluate implemented resources * refactor
This commit is contained in:
@@ -6,15 +6,16 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/accesscontrol")
|
||||
|
||||
@@ -3,32 +3,52 @@ package acimpl
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
var (
|
||||
errAccessNotImplemented = errors.New("access control not implemented for resource")
|
||||
)
|
||||
|
||||
var _ accesscontrol.AccessControl = new(AccessControl)
|
||||
|
||||
func ProvideAccessControl(features featuremgmt.FeatureToggles) *AccessControl {
|
||||
func ProvideAccessControl(features featuremgmt.FeatureToggles, zclient zanzana.Client) *AccessControl {
|
||||
logger := log.New("accesscontrol")
|
||||
return &AccessControl{
|
||||
features, logger, accesscontrol.NewResolvers(logger),
|
||||
features, logger, accesscontrol.NewResolvers(logger), zclient,
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideAccessControlTest() *AccessControl {
|
||||
return ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
|
||||
}
|
||||
|
||||
type AccessControl struct {
|
||||
features featuremgmt.FeatureToggles
|
||||
log log.Logger
|
||||
resolvers accesscontrol.Resolvers
|
||||
zclient zanzana.Client
|
||||
}
|
||||
|
||||
func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
if a.features.IsEnabledGlobally(featuremgmt.FlagZanzana) {
|
||||
return a.evaluateCompare(ctx, user, evaluator)
|
||||
}
|
||||
|
||||
return a.evaluate(ctx, user, evaluator)
|
||||
}
|
||||
|
||||
func (a *AccessControl) evaluate(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
timer := prometheus.NewTimer(metrics.MAccessEvaluationsSummary)
|
||||
defer timer.ObserveDuration()
|
||||
metrics.MAccessEvaluationCount.Inc()
|
||||
@@ -66,6 +86,87 @@ func (a *AccessControl) Evaluate(ctx context.Context, user identity.Requester, e
|
||||
return resolvedEvaluator.Evaluate(permissions), nil
|
||||
}
|
||||
|
||||
func (a *AccessControl) evaluateZanzana(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
eval, err := evaluator.MutateScopes(ctx, a.resolvers.GetScopeAttributeMutator(user.GetOrgID()))
|
||||
if err != nil {
|
||||
if !errors.Is(err, accesscontrol.ErrResolverNotFound) {
|
||||
return false, err
|
||||
}
|
||||
eval = evaluator
|
||||
}
|
||||
|
||||
return eval.EvaluateCustom(func(action, scope string) (bool, error) {
|
||||
kind, _, identifier := accesscontrol.SplitScope(scope)
|
||||
key, ok := zanzana.TranslateToTuple(user.GetUID().String(), action, kind, identifier, user.GetOrgID())
|
||||
if !ok {
|
||||
// unsupported translation
|
||||
return false, errAccessNotImplemented
|
||||
}
|
||||
|
||||
res, err := a.zclient.Check(ctx, &openfgav1.CheckRequest{
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: key.User,
|
||||
Relation: key.Relation,
|
||||
Object: key.Object,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return res.Allowed, nil
|
||||
})
|
||||
}
|
||||
|
||||
type evalResult struct {
|
||||
runner string
|
||||
decision bool
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// evaluateCompare run RBAC and zanzana checks in parallel and then compare result
|
||||
func (a *AccessControl) evaluateCompare(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
res := make(chan evalResult, 2)
|
||||
go func() {
|
||||
start := time.Now()
|
||||
hasAccess, err := a.evaluateZanzana(ctx, user, evaluator)
|
||||
res <- evalResult{"zanzana", hasAccess, err, time.Since(start)}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
start := time.Now()
|
||||
hasAccess, err := a.evaluate(ctx, user, evaluator)
|
||||
res <- evalResult{"grafana", hasAccess, err, time.Since(start)}
|
||||
}()
|
||||
first, second := <-res, <-res
|
||||
close(res)
|
||||
|
||||
if second.runner == "grafana" {
|
||||
first, second = second, first
|
||||
}
|
||||
|
||||
if !errors.Is(second.err, errAccessNotImplemented) {
|
||||
if second.err != nil {
|
||||
a.log.Error("zanzana evaluation failed", "error", second.err)
|
||||
} else if first.decision != second.decision {
|
||||
a.log.Warn(
|
||||
"zanzana evaluation result does not match grafana",
|
||||
"grafana_decision", first.decision,
|
||||
"zanana_decision", second.decision,
|
||||
"grafana_ms", first.duration,
|
||||
"zanzana_ms", second.duration,
|
||||
"eval", evaluator.GoString(),
|
||||
)
|
||||
} else {
|
||||
a.log.Debug("zanzana evaluation is correct", "grafana_ms", first.duration, "zanzana_ms", second.duration)
|
||||
}
|
||||
}
|
||||
|
||||
return first.decision, first.err
|
||||
}
|
||||
|
||||
func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
|
||||
a.resolvers.AddScopeAttributeResolver(prefix, resolver)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
@@ -65,7 +66,7 @@ func TestAccessControl_Evaluate(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets))
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets), zanzana.NewNoopClient())
|
||||
|
||||
if tt.scopeResolver != nil {
|
||||
ac.RegisterScopeAttributeResolver(tt.resolverPrefix, tt.scopeResolver)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthorizeInOrgMiddleware(t *testing.T) {
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
|
||||
|
||||
// Define test cases
|
||||
testCases := []struct {
|
||||
|
||||
@@ -11,9 +11,13 @@ import (
|
||||
|
||||
var logger = log.New("accesscontrol.evaluator")
|
||||
|
||||
type CheckerFn func(action string, scope string) (bool, error)
|
||||
|
||||
type Evaluator interface {
|
||||
// Evaluate permissions that are grouped by action
|
||||
Evaluate(permissions map[string][]string) bool
|
||||
// EvaluateCustom allows to perform evaluation with custom check function
|
||||
EvaluateCustom(fn CheckerFn) (bool, error)
|
||||
// MutateScopes executes a sequence of ScopeModifier functions on all embedded scopes of an evaluator and returns a new Evaluator
|
||||
MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error)
|
||||
// String returns a string representation of permission required by the evaluator
|
||||
@@ -80,6 +84,25 @@ func match(scope, target string) bool {
|
||||
return scope == target
|
||||
}
|
||||
|
||||
func (p permissionEvaluator) EvaluateCustom(fn CheckerFn) (bool, error) {
|
||||
if len(p.Scopes) == 0 {
|
||||
return fn(p.Action, "")
|
||||
}
|
||||
|
||||
for _, target := range p.Scopes {
|
||||
matches, err := fn(p.Action, target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if matches {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (p permissionEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
|
||||
if p.Scopes == nil {
|
||||
return EvalPermission(p.Action), nil
|
||||
@@ -135,6 +158,19 @@ func (a allEvaluator) Evaluate(permissions map[string][]string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a allEvaluator) EvaluateCustom(fn CheckerFn) (bool, error) {
|
||||
for _, e := range a.allOf {
|
||||
allowed, err := e.EvaluateCustom(fn)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a allEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
|
||||
resolved := false
|
||||
modified := make([]Evaluator, 0, len(a.allOf))
|
||||
@@ -195,6 +231,19 @@ func (a anyEvaluator) Evaluate(permissions map[string][]string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a anyEvaluator) EvaluateCustom(fn CheckerFn) (bool, error) {
|
||||
for _, e := range a.anyOf {
|
||||
allowed, err := e.EvaluateCustom(fn)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if allowed {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a anyEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
|
||||
resolved := false
|
||||
modified := make([]Evaluator, 0, len(a.anyOf))
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -25,7 +26,7 @@ type middlewareTestCase struct {
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
|
||||
|
||||
tests := []middlewareTestCase{
|
||||
{
|
||||
@@ -81,7 +82,7 @@ func TestMiddleware_forceLogin(t *testing.T) {
|
||||
{url: "/endpoint"},
|
||||
}
|
||||
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.url, func(t *testing.T) {
|
||||
|
||||
@@ -214,19 +214,7 @@ func (p Permission) OSSPermission() Permission {
|
||||
|
||||
// SplitScope returns kind, attribute and Identifier
|
||||
func (p Permission) SplitScope() (string, string, string) {
|
||||
if p.Scope == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
fragments := strings.Split(p.Scope, ":")
|
||||
switch l := len(fragments); l {
|
||||
case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[0], fragments[0]
|
||||
case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[1], fragments[1]
|
||||
default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash"
|
||||
return fragments[0], fragments[1], strings.Join(fragments[2:], ":")
|
||||
}
|
||||
return SplitScope(p.Scope)
|
||||
}
|
||||
|
||||
type GetUserPermissionsQuery struct {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/licensing/licensingtest"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgimpl"
|
||||
@@ -289,7 +290,7 @@ func TestService_RegisterActionSets(t *testing.T) {
|
||||
if tt.actionSetsEnabled {
|
||||
features = featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets)
|
||||
}
|
||||
ac := acimpl.ProvideAccessControl(features)
|
||||
ac := acimpl.ProvideAccessControl(features, zanzana.NewNoopClient())
|
||||
actionSets := NewActionSetService()
|
||||
_, err := New(
|
||||
setting.NewCfg(), tt.options, features, routing.NewRouteRegister(), licensingtest.NewFakeLicensing(),
|
||||
@@ -335,7 +336,7 @@ func setupTestEnvironment(t *testing.T, ops Options) (*Service, user.Service, te
|
||||
license := licensingtest.NewFakeLicensing()
|
||||
license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe()
|
||||
acService := &actest.FakeService{}
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
|
||||
service, err := New(
|
||||
cfg, ops, featuremgmt.WithFeatures(), routing.NewRouteRegister(), license,
|
||||
ac, acService, sql, teamSvc, userSvc, NewActionSetService(),
|
||||
|
||||
@@ -10,6 +10,23 @@ const (
|
||||
maxPrefixParts = 2
|
||||
)
|
||||
|
||||
// SplitScope returns kind, attribute and Identifier
|
||||
func SplitScope(scope string) (string, string, string) {
|
||||
if scope == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
fragments := strings.Split(scope, ":")
|
||||
switch l := len(fragments); l {
|
||||
case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[0], fragments[0]
|
||||
case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*"
|
||||
return fragments[0], fragments[1], fragments[1]
|
||||
default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash"
|
||||
return fragments[0], fragments[1], strings.Join(fragments[2:], ":")
|
||||
}
|
||||
}
|
||||
|
||||
func ParseScopeID(scope string) (int64, error) {
|
||||
id, err := strconv.ParseInt(ScopeSuffix(scope), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user