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:
Alexander Zobnin
2024-07-05 11:31:23 +02:00
committed by GitHub
parent 48e6e9a36c
commit 87d86e81ce
44 changed files with 295 additions and 98 deletions
+4 -3
View File
@@ -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 {
+49
View File
@@ -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) {
+1 -13
View File
@@ -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(),
+17
View File
@@ -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 {