Access Control: Refactor scope resolvers with support to resolve into several scopes (#48202)

* Refactor Scope resolver to support resolving into several scopes

* Change permission evaluator to match at least one of passed scopes
This commit is contained in:
Karl Persson 2022-05-02 09:29:30 +02:00 committed by GitHub
parent 9622e7457e
commit de50f39c12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 453 additions and 434 deletions

View File

@ -401,20 +401,21 @@ func (hs *HTTPServer) GetAnnotationTags(c *models.ReqContext) response.Response
return response.JSON(http.StatusOK, annotations.GetAnnotationTagsResponse{Result: result}) return response.JSON(http.StatusOK, annotations.GetAnnotationTagsResponse{Result: result})
} }
// AnnotationTypeScopeResolver provides an AttributeScopeResolver able to // AnnotationTypeScopeResolver provides an ScopeAttributeResolver able to
// resolve annotation types. Scope "annotations:id:<id>" will be translated to "annotations:type:<type>, // resolve annotation types. Scope "annotations:id:<id>" will be translated to "annotations:type:<type>,
// where <type> is the type of annotation with id <id>. // where <type> is the type of annotation with id <id>.
func AnnotationTypeScopeResolver() (string, accesscontrol.AttributeScopeResolveFunc) { func AnnotationTypeScopeResolver() (string, accesscontrol.ScopeAttributeResolver) {
annotationTypeResolver := func(ctx context.Context, orgID int64, initialScope string) (string, error) { prefix := accesscontrol.ScopeAnnotationsProvider.GetResourceScope("")
return prefix, accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
scopeParts := strings.Split(initialScope, ":") scopeParts := strings.Split(initialScope, ":")
if scopeParts[0] != accesscontrol.ScopeAnnotationsRoot || len(scopeParts) != 3 { if scopeParts[0] != accesscontrol.ScopeAnnotationsRoot || len(scopeParts) != 3 {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
annotationIdStr := scopeParts[2] annotationIdStr := scopeParts[2]
annotationId, err := strconv.Atoi(annotationIdStr) annotationId, err := strconv.Atoi(annotationIdStr)
if err != nil { if err != nil {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
// tempUser is used to resolve annotation type. // tempUser is used to resolve annotation type.
@ -431,16 +432,15 @@ func AnnotationTypeScopeResolver() (string, accesscontrol.AttributeScopeResolveF
annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), tempUser) annotation, resp := findAnnotationByID(ctx, annotations.GetRepository(), int64(annotationId), tempUser)
if resp != nil { if resp != nil {
return "", errors.New("could not resolve annotation type") return nil, errors.New("could not resolve annotation type")
} }
if annotation.GetType() == annotations.Organization { if annotation.GetType() == annotations.Organization {
return accesscontrol.ScopeAnnotationsTypeOrganization, nil return []string{accesscontrol.ScopeAnnotationsTypeOrganization}, nil
} else { } else {
return accesscontrol.ScopeAnnotationsTypeDashboard, nil return []string{accesscontrol.ScopeAnnotationsTypeDashboard}, nil
} }
} })
return accesscontrol.ScopeAnnotationsProvider.GetResourceScope(""), annotationTypeResolver
} }
func (hs *HTTPServer) canCreateAnnotation(c *models.ReqContext, dashboardId int64) (bool, error) { func (hs *HTTPServer) canCreateAnnotation(c *models.ReqContext, dashboardId int64) (bool, error) {

View File

@ -722,7 +722,7 @@ func TestAPI_Annotations_AccessControl(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
setUpRBACGuardian(t) setUpRBACGuardian(t)
sc.acmock. sc.acmock.
RegisterAttributeScopeResolver(AnnotationTypeScopeResolver()) RegisterScopeAttributeResolver(AnnotationTypeScopeResolver())
setAccessControlPermissions(sc.acmock, tt.args.permissions, sc.initCtx.OrgId) setAccessControlPermissions(sc.acmock, tt.args.permissions, sc.initCtx.OrgId)
r := callAPI(sc.server, tt.args.method, tt.args.url, tt.args.body, t) r := callAPI(sc.server, tt.args.method, tt.args.url, tt.args.body, t)
@ -780,13 +780,14 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
resolved, err := resolver(context.Background(), 1, tc.given) resolved, err := resolver.Resolve(context.Background(), 1, tc.given)
if tc.wantErr != nil { if tc.wantErr != nil {
require.Error(t, err) require.Error(t, err)
require.Equal(t, tc.wantErr, err) require.Equal(t, tc.wantErr, err)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.want, resolved) require.Len(t, resolved, 1)
require.Equal(t, tc.want, resolved[0])
} }
}) })
} }

View File

@ -265,7 +265,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
hs.registerRoutes() hs.registerRoutes()
// Register access control scope resolver for annotations // Register access control scope resolver for annotations
hs.AccessControl.RegisterAttributeScopeResolver(AnnotationTypeScopeResolver()) hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver())
if err := hs.declareFixedRoles(); err != nil { if err := hs.declareFixedRoles(); err != nil {
return nil, err return nil, err

View File

@ -32,9 +32,9 @@ type AccessControl interface {
// assignments to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" // assignments to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin"
DeclareFixedRoles(...RoleRegistration) error DeclareFixedRoles(...RoleRegistration) error
// RegisterAttributeScopeResolver allows the caller to register a scope resolver for a // RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
// specific scope prefix (ex: datasources:name:) // specific scope prefix (ex: datasources:name:)
RegisterAttributeScopeResolver(scopePrefix string, resolver AttributeScopeResolveFunc) RegisterScopeAttributeResolver(scopePrefix string, resolver ScopeAttributeResolver)
} }
type PermissionsProvider interface { type PermissionsProvider interface {

View File

@ -14,7 +14,7 @@ type Evaluator interface {
// Evaluate permissions that are grouped by action // Evaluate permissions that are grouped by action
Evaluate(permissions map[string][]string) (bool, error) Evaluate(permissions map[string][]string) (bool, error)
// MutateScopes executes a sequence of ScopeModifier functions on all embedded scopes of an evaluator and returns a new Evaluator // MutateScopes executes a sequence of ScopeModifier functions on all embedded scopes of an evaluator and returns a new Evaluator
MutateScopes(context.Context, ...ScopeMutator) (Evaluator, error) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error)
// String returns a string representation of permission required by the evaluator // String returns a string representation of permission required by the evaluator
fmt.Stringer fmt.Stringer
fmt.GoStringer fmt.GoStringer
@ -22,7 +22,7 @@ type Evaluator interface {
var _ Evaluator = new(permissionEvaluator) var _ Evaluator = new(permissionEvaluator)
// EvalPermission returns an evaluator that will require all scopes in combination with action to match // EvalPermission returns an evaluator that will require at least one of passed scopes to match
func EvalPermission(action string, scopes ...string) Evaluator { func EvalPermission(action string, scopes ...string) Evaluator {
return permissionEvaluator{Action: action, Scopes: scopes} return permissionEvaluator{Action: action, Scopes: scopes}
} }
@ -43,29 +43,19 @@ func (p permissionEvaluator) Evaluate(permissions map[string][]string) (bool, er
} }
for _, target := range p.Scopes { for _, target := range p.Scopes {
var err error
var matches bool
for _, scope := range userScopes { for _, scope := range userScopes {
matches, err = match(scope, target) if match(scope, target) {
if err != nil { return true, nil
return false, err
} }
if matches {
break
}
}
if !matches {
return false, nil
} }
} }
return true, nil return false, nil
} }
func match(scope, target string) (bool, error) { func match(scope, target string) bool {
if scope == "" { if scope == "" {
return false, nil return false
} }
if !ValidateScope(scope) { if !ValidateScope(scope) {
@ -74,7 +64,7 @@ func match(scope, target string) (bool, error) {
"scope", scope, "scope", scope,
"reason", "scopes should not contain meta-characters like * or ?, except in the last position", "reason", "scopes should not contain meta-characters like * or ?, except in the last position",
) )
return false, nil return false
} }
prefix, last := scope[:len(scope)-1], scope[len(scope)-1] prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
@ -82,29 +72,25 @@ func match(scope, target string) (bool, error) {
if last == '*' { if last == '*' {
if strings.HasPrefix(target, prefix) { if strings.HasPrefix(target, prefix) {
logger.Debug("matched scope", "user scope", scope, "target scope", target) logger.Debug("matched scope", "user scope", scope, "target scope", target)
return true, nil return true
} }
} }
return scope == target, nil return scope == target
} }
func (p permissionEvaluator) MutateScopes(ctx context.Context, modifiers ...ScopeMutator) (Evaluator, error) { func (p permissionEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
var err error
if p.Scopes == nil { if p.Scopes == nil {
return EvalPermission(p.Action), nil return EvalPermission(p.Action), nil
} }
scopes := make([]string, 0, len(p.Scopes)) scopes := make([]string, 0, len(p.Scopes))
for _, scope := range p.Scopes { for _, scope := range p.Scopes {
modified := scope mutated, err := mutate(ctx, scope)
for _, modifier := range modifiers {
modified, err = modifier(ctx, modified)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} scopes = append(scopes, mutated...)
scopes = append(scopes, modified)
} }
return EvalPermission(p.Action, scopes...), nil return EvalPermission(p.Action, scopes...), nil
} }
@ -137,10 +123,10 @@ func (a allEvaluator) Evaluate(permissions map[string][]string) (bool, error) {
return true, nil return true, nil
} }
func (a allEvaluator) MutateScopes(ctx context.Context, modifiers ...ScopeMutator) (Evaluator, error) { func (a allEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
var modified []Evaluator var modified []Evaluator
for _, e := range a.allOf { for _, e := range a.allOf {
i, err := e.MutateScopes(ctx, modifiers...) i, err := e.MutateScopes(ctx, mutate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -191,10 +177,10 @@ func (a anyEvaluator) Evaluate(permissions map[string][]string) (bool, error) {
return false, nil return false, nil
} }
func (a anyEvaluator) MutateScopes(ctx context.Context, modifiers ...ScopeMutator) (Evaluator, error) { func (a anyEvaluator) MutateScopes(ctx context.Context, mutate ScopeAttributeMutator) (Evaluator, error) {
var modified []Evaluator var modified []Evaluator
for _, e := range a.anyOf { for _, e := range a.anyOf {
i, err := e.MutateScopes(ctx, modifiers...) i, err := e.MutateScopes(ctx, mutate)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -25,11 +25,11 @@ func TestPermission_Evaluate(t *testing.T) {
}, },
}, },
{ {
desc: "should evaluate to true when allEvaluator required scopes matches", desc: "should evaluate to true when at least one scope matches",
expected: true, expected: true,
evaluator: EvalPermission("reports:read", "reports:1", "reports:2"), evaluator: EvalPermission("reports:read", "reports:1", "reports:2"),
permissions: map[string][]string{ permissions: map[string][]string{
"reports:read": {"reports:1", "reports:2"}, "reports:read": {"reports:2"},
}, },
}, },
{ {
@ -41,11 +41,11 @@ func TestPermission_Evaluate(t *testing.T) {
}, },
}, },
{ {
desc: "should evaluate to false when only one of required scopes exists", desc: "should evaluate to false when no scopes matches",
expected: false, expected: false,
evaluator: EvalPermission("reports:read", "reports:1", "reports:2"), evaluator: EvalPermission("reports:read", "reports:1", "reports:2"),
permissions: map[string][]string{ permissions: map[string][]string{
"reports:read": {"reports:1"}, "reports:read": {"reports:9", "reports:10"},
}, },
}, },
} }

View File

@ -45,9 +45,9 @@ type Mock struct {
DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error
GetUserBuiltInRolesFunc func(user *models.SignedInUser) []string GetUserBuiltInRolesFunc func(user *models.SignedInUser) []string
RegisterFixedRolesFunc func() error RegisterFixedRolesFunc func() error
RegisterAttributeScopeResolverFunc func(string, accesscontrol.AttributeScopeResolveFunc) RegisterScopeAttributeResolverFunc func(string, accesscontrol.ScopeAttributeResolver)
scopeResolver accesscontrol.ScopeResolver scopeResolvers accesscontrol.ScopeResolvers
} }
// Ensure the mock stays in line with the interface // Ensure the mock stays in line with the interface
@ -59,7 +59,7 @@ func New() *Mock {
disabled: false, disabled: false,
permissions: []*accesscontrol.Permission{}, permissions: []*accesscontrol.Permission{},
builtInRoles: []string{}, builtInRoles: []string{},
scopeResolver: accesscontrol.NewScopeResolver(), scopeResolvers: accesscontrol.NewScopeResolvers(),
} }
return mock return mock
@ -98,7 +98,7 @@ func (m *Mock) Evaluate(ctx context.Context, user *models.SignedInUser, evaluato
return false, err return false, err
} }
attributeMutator := m.scopeResolver.GetResolveAttributeScopeMutator(user.OrgId) attributeMutator := m.scopeResolvers.GetScopeAttributeMutator(user.OrgId)
resolvedEvaluator, err := evaluator.MutateScopes(ctx, attributeMutator) resolvedEvaluator, err := evaluator.MutateScopes(ctx, attributeMutator)
if err != nil { if err != nil {
return false, err return false, err
@ -178,13 +178,11 @@ func (m *Mock) RegisterFixedRoles(ctx context.Context) error {
return nil return nil
} }
// RegisterAttributeScopeResolver allows the caller to register a scope resolver for a func (m *Mock) RegisterScopeAttributeResolver(scopePrefix string, resolver accesscontrol.ScopeAttributeResolver) {
// specific scope prefix (ex: datasources:name:) m.scopeResolvers.AddScopeAttributeResolver(scopePrefix, resolver)
func (m *Mock) RegisterAttributeScopeResolver(scopePrefix string, resolver accesscontrol.AttributeScopeResolveFunc) {
m.scopeResolver.AddAttributeResolver(scopePrefix, resolver)
m.Calls.RegisterAttributeScopeResolver = append(m.Calls.RegisterAttributeScopeResolver, []struct{}{}) m.Calls.RegisterAttributeScopeResolver = append(m.Calls.RegisterAttributeScopeResolver, []struct{}{})
// Use override if provided // Use override if provided
if m.RegisterAttributeScopeResolverFunc != nil { if m.RegisterScopeAttributeResolverFunc != nil {
m.RegisterAttributeScopeResolverFunc(scopePrefix, resolver) m.RegisterScopeAttributeResolverFunc(scopePrefix, resolver)
} }
} }

View File

@ -36,7 +36,7 @@ func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider acces
features: features, features: features,
provider: provider, provider: provider,
log: log.New("accesscontrol"), log: log.New("accesscontrol"),
scopeResolver: accesscontrol.NewScopeResolver(), scopeResolvers: accesscontrol.NewScopeResolvers(),
roles: accesscontrol.BuildMacroRoleDefinitions(), roles: accesscontrol.BuildMacroRoleDefinitions(),
} }
@ -47,7 +47,7 @@ func ProvideOSSAccessControl(features featuremgmt.FeatureToggles, provider acces
type OSSAccessControlService struct { type OSSAccessControlService struct {
log log.Logger log log.Logger
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
scopeResolver accesscontrol.ScopeResolver scopeResolvers accesscontrol.ScopeResolvers
provider accesscontrol.PermissionsProvider provider accesscontrol.PermissionsProvider
registrations accesscontrol.RegistrationList registrations accesscontrol.RegistrationList
roles map[string]*accesscontrol.RoleDTO roles map[string]*accesscontrol.RoleDTO
@ -92,7 +92,7 @@ func (ac *OSSAccessControlService) Evaluate(ctx context.Context, user *models.Si
user.Permissions[user.OrgId] = accesscontrol.GroupScopesByAction(permissions) user.Permissions[user.OrgId] = accesscontrol.GroupScopesByAction(permissions)
} }
attributeMutator := ac.scopeResolver.GetResolveAttributeScopeMutator(user.OrgId) attributeMutator := ac.scopeResolvers.GetScopeAttributeMutator(user.OrgId)
resolvedEvaluator, err := evaluator.MutateScopes(ctx, attributeMutator) resolvedEvaluator, err := evaluator.MutateScopes(ctx, attributeMutator)
if err != nil { if err != nil {
return false, err return false, err
@ -124,7 +124,7 @@ func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user
permissions = append(permissions, dbPermissions...) permissions = append(permissions, dbPermissions...)
resolved := make([]*accesscontrol.Permission, 0, len(permissions)) resolved := make([]*accesscontrol.Permission, 0, len(permissions))
keywordMutator := ac.scopeResolver.GetResolveKeywordScopeMutator(user) keywordMutator := ac.scopeResolvers.GetScopeKeywordMutator(user)
for _, p := range permissions { for _, p := range permissions {
// if the permission has a keyword in its scope it will be resolved // if the permission has a keyword in its scope it will be resolved
p.Scope, err = keywordMutator(ctx, p.Scope) p.Scope, err = keywordMutator(ctx, p.Scope)
@ -217,8 +217,8 @@ func (ac *OSSAccessControlService) DeclareFixedRoles(registrations ...accesscont
return nil return nil
} }
// RegisterAttributeScopeResolver allows the caller to register scope resolvers for a // RegisterScopeAttributeResolver allows the caller to register scope resolvers for a
// specific scope prefix (ex: datasources:name:) // specific scope prefix (ex: datasources:name:)
func (ac *OSSAccessControlService) RegisterAttributeScopeResolver(scopePrefix string, resolver accesscontrol.AttributeScopeResolveFunc) { func (ac *OSSAccessControlService) RegisterScopeAttributeResolver(scopePrefix string, resolver accesscontrol.ScopeAttributeResolver) {
ac.scopeResolver.AddAttributeResolver(scopePrefix, resolver) ac.scopeResolvers.AddScopeAttributeResolver(scopePrefix, resolver)
} }

View File

@ -23,7 +23,7 @@ func setupTestEnv(t testing.TB) *OSSAccessControlService {
features: featuremgmt.WithFeatures(featuremgmt.FlagAccesscontrol), features: featuremgmt.WithFeatures(featuremgmt.FlagAccesscontrol),
log: log.New("accesscontrol"), log: log.New("accesscontrol"),
registrations: accesscontrol.RegistrationList{}, registrations: accesscontrol.RegistrationList{},
scopeResolver: accesscontrol.NewScopeResolver(), scopeResolvers: accesscontrol.NewScopeResolvers(),
provider: database.ProvideService(sqlstore.InitTestDB(t)), provider: database.ProvideService(sqlstore.InitTestDB(t)),
roles: accesscontrol.BuildMacroRoleDefinitions(), roles: accesscontrol.BuildMacroRoleDefinitions(),
} }
@ -439,12 +439,12 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) {
}, },
Grants: []string{"Viewer"}, Grants: []string{"Viewer"},
} }
userLoginScopeSolver := func(ctx context.Context, orgID int64, initialScope string) (string, error) { userLoginScopeSolver := accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
if initialScope == "users:login:testUser" { if initialScope == "users:login:testUser" {
return "users:id:2", nil return []string{"users:id:2"}, nil
}
return initialScope, nil
} }
return []string{initialScope}, nil
})
tests := []struct { tests := []struct {
name string name string
@ -475,7 +475,7 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Setup // Setup
ac := setupTestEnv(t) ac := setupTestEnv(t)
ac.RegisterAttributeScopeResolver("users:login:", userLoginScopeSolver) ac.RegisterScopeAttributeResolver("users:login:", userLoginScopeSolver)
registration.Role.Permissions = []accesscontrol.Permission{tt.rawPerm} registration.Role.Permissions = []accesscontrol.Permission{tt.rawPerm}
err := ac.DeclareFixedRoles(registration) err := ac.DeclareFixedRoles(registration)

View File

@ -0,0 +1,140 @@
package accesscontrol
import (
"bytes"
"context"
"fmt"
"text/template"
"time"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
)
const (
ttl = 30 * time.Second
cleanInterval = 2 * time.Minute
)
func NewScopeResolvers() ScopeResolvers {
return ScopeResolvers{
keywordResolvers: map[string]ScopeKeywordResolver{
"users:self": userSelfResolver,
},
attributeResolvers: map[string]ScopeAttributeResolver{},
cache: localcache.New(ttl, cleanInterval),
log: log.New("accesscontrol.resolver"),
}
}
type ScopeResolvers struct {
log log.Logger
cache *localcache.CacheService
keywordResolvers map[string]ScopeKeywordResolver
attributeResolvers map[string]ScopeAttributeResolver
}
func (s *ScopeResolvers) GetScopeAttributeMutator(orgID int64) ScopeAttributeMutator {
return func(ctx context.Context, scope string) ([]string, error) {
key := getScopeCacheKey(orgID, scope)
// Check cache before computing the scope
if cachedScope, ok := s.cache.Get(key); ok {
scopes := cachedScope.([]string)
s.log.Debug("used cache to resolve '%v' to '%v'", scope, scopes)
return scopes, nil
}
prefix := ScopePrefix(scope)
if resolver, ok := s.attributeResolvers[prefix]; ok {
scopes, err := resolver.Resolve(ctx, orgID, scope)
if err != nil {
return nil, fmt.Errorf("could not resolve %v: %w", scope, err)
}
// Cache result
s.cache.Set(key, scopes, ttl)
s.log.Debug("resolved '%v' to '%v'", scope, scopes)
return scopes, nil
}
return []string{scope}, nil
}
}
func (s *ScopeResolvers) GetScopeKeywordMutator(user *models.SignedInUser) ScopeKeywordMutator {
return func(ctx context.Context, scope string) (string, error) {
if resolver, ok := s.keywordResolvers[scope]; ok {
scopes, err := resolver.Resolve(ctx, user)
if err != nil {
return "", fmt.Errorf("could not resolve %v: %w", scope, err)
}
s.log.Debug("resolved '%v' to '%v'", scope, scopes)
return scopes, nil
}
// By default, the scope remains unchanged
return scope, nil
}
}
func (s *ScopeResolvers) AddScopeKeywordResolver(keyword string, resolver ScopeKeywordResolver) {
s.log.Debug("adding scope keyword resolver for '%v'", keyword)
s.keywordResolvers[keyword] = resolver
}
func (s *ScopeResolvers) AddScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver) {
s.log.Debug("adding scope attribute resolver for '%v'", prefix)
s.attributeResolvers[prefix] = resolver
}
// ScopeAttributeResolver is used to resolve attributes in scopes to one or more scopes that are
// evaluated by logical or. E.g. "dashboards:id:1" -> "dashboards:uid:test-dashboard" or "folder:uid:test-folder"
type ScopeAttributeResolver interface {
Resolve(ctx context.Context, orgID int64, scope string) ([]string, error)
}
// ScopeAttributeResolverFunc is an adapter to allow functions to implement ScopeAttributeResolver interface
type ScopeAttributeResolverFunc func(ctx context.Context, orgID int64, scope string) ([]string, error)
func (f ScopeAttributeResolverFunc) Resolve(ctx context.Context, orgID int64, scope string) ([]string, error) {
return f(ctx, orgID, scope)
}
type ScopeAttributeMutator func(context.Context, string) ([]string, error)
// ScopeKeywordResolver is used to resolve keywords in scopes e.g. "users:self" -> "user:id:1".
// These type of resolvers is used when fetching stored permissions
type ScopeKeywordResolver interface {
Resolve(ctx context.Context, user *models.SignedInUser) (string, error)
}
// ScopeKeywordResolverFunc is an adapter to allow functions to implement ScopeKeywordResolver interface
type ScopeKeywordResolverFunc func(ctx context.Context, user *models.SignedInUser) (string, error)
func (f ScopeKeywordResolverFunc) Resolve(ctx context.Context, user *models.SignedInUser) (string, error) {
return f(ctx, user)
}
type ScopeKeywordMutator func(context.Context, string) (string, error)
// getScopeCacheKey creates an identifier to fetch and store resolution of scopes in the cache
func getScopeCacheKey(orgID int64, scope string) string {
return fmt.Sprintf("%s-%v", scope, orgID)
}
//ScopeInjector inject request params into the templated scopes. e.g. "settings:" + eval.Parameters(":id")
func ScopeInjector(params ScopeParams) ScopeAttributeMutator {
return func(_ context.Context, scope string) ([]string, error) {
tmpl, err := template.New("scope").Parse(scope)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err = tmpl.Execute(&buf, params); err != nil {
return nil, err
}
return []string{buf.String()}, nil
}
}
var userSelfResolver = ScopeKeywordResolverFunc(func(ctx context.Context, user *models.SignedInUser) (string, error) {
return Scope("users", "id", fmt.Sprintf("%v", user.UserId)), nil
})

View File

@ -0,0 +1,159 @@
package accesscontrol
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestResolveKeywordScope(t *testing.T) {
tests := []struct {
name string
user *models.SignedInUser
permission Permission
want Permission
wantErr bool
}{
{
name: "no scope",
user: testUser,
permission: Permission{Action: "users:read"},
want: Permission{Action: "users:read"},
wantErr: false,
},
{
name: "user if resolution",
user: testUser,
permission: Permission{Action: "users:read", Scope: "users:self"},
want: Permission{Action: "users:read", Scope: "users:id:2"},
wantErr: false,
},
}
for _, tt := range tests {
var err error
t.Run(tt.name, func(t *testing.T) {
resolvers := NewScopeResolvers()
scopeModifier := resolvers.GetScopeKeywordMutator(tt.user)
tt.permission.Scope, err = scopeModifier(context.TODO(), tt.permission.Scope)
if tt.wantErr {
assert.Error(t, err, "expected an error during the resolution of the scope")
return
}
assert.NoError(t, err)
assert.EqualValues(t, tt.want, tt.permission, "permission did not match expected resolution")
})
}
}
var testUser = &models.SignedInUser{
UserId: 2,
OrgId: 3,
OrgName: "TestOrg",
OrgRole: models.ROLE_VIEWER,
Login: "testUser",
Name: "Test User",
Email: "testuser@example.org",
}
func TestResolveAttributeScope(t *testing.T) {
// Calls allow us to see how many times the fakeDataSourceResolution has been called
calls := 0
fakeDataSourceResolver := ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
calls++
if initialScope == "datasources:name:testds" {
return []string{Scope("datasources", "id", "1")}, nil
} else if initialScope == "datasources:name:testds2" {
return []string{Scope("datasources", "id", "2")}, nil
} else if initialScope == "datasources:name:test:ds4" {
return []string{Scope("datasources", "id", "4")}, nil
} else if initialScope == "datasources:name:testds5*" {
return []string{Scope("datasources", "id", "5")}, nil
} else {
return nil, models.ErrDataSourceNotFound
}
})
tests := []struct {
name string
orgID int64
evaluator Evaluator
wantEvaluator Evaluator
wantCalls int
wantErr error
}{
{
name: "should work with scope less permissions",
evaluator: EvalPermission("datasources:read"),
wantEvaluator: EvalPermission("datasources:read"),
wantCalls: 0,
},
{
name: "should handle an error",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds3")),
wantErr: models.ErrDataSourceNotFound,
wantCalls: 1,
},
{
name: "should resolve a scope",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "1")),
wantCalls: 1,
},
{
name: "should resolve nested scopes with cache",
orgID: 1,
evaluator: EvalAll(
EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
EvalAny(
EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
EvalPermission("datasources:read", Scope("datasources", "name", "testds2")),
),
),
wantEvaluator: EvalAll(
EvalPermission("datasources:read", Scope("datasources", "id", "1")),
EvalAny(
EvalPermission("datasources:read", Scope("datasources", "id", "1")),
EvalPermission("datasources:read", Scope("datasources", "id", "2")),
),
),
wantCalls: 2,
},
{
name: "should resolve name with colon",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "test:ds4")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "4")),
wantCalls: 1,
},
{
name: "should resolve names with '*'",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds5*")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "5")),
wantCalls: 1,
},
}
for _, tt := range tests {
resolvers := NewScopeResolvers()
// Reset calls counter
calls = 0
// Register a resolution method
resolvers.AddScopeAttributeResolver("datasources:name:", fakeDataSourceResolver)
// Test
mutate := resolvers.GetScopeAttributeMutator(tt.orgID)
resolvedEvaluator, err := tt.evaluator.MutateScopes(context.Background(), mutate)
if tt.wantErr != nil {
assert.ErrorAs(t, err, &tt.wantErr, "expected an error during the resolution of the scope")
return
}
assert.NoError(t, err)
assert.EqualValues(t, tt.wantEvaluator, resolvedEvaluator, "permission did not match expected resolution")
assert.Equal(t, tt.wantCalls, calls, "cache has not been used")
}
}

View File

@ -1,21 +1,11 @@
package accesscontrol package accesscontrol
import ( import (
"bytes"
"context"
"fmt" "fmt"
"html/template"
"strings" "strings"
"time"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
) )
const ( const (
ttl = 30 * time.Second
cleanInterval = 2 * time.Minute
maxPrefixParts = 2 maxPrefixParts = 2
) )
@ -68,95 +58,6 @@ func Field(key string) string {
return fmt.Sprintf(`{{ .%s }}`, key) return fmt.Sprintf(`{{ .%s }}`, key)
} }
// ScopeMutator alters a Scope to return a new modified Scope
type ScopeMutator func(context.Context, string) (string, error)
type KeywordScopeResolveFunc func(*models.SignedInUser) (string, error)
// ScopeResolver is used to resolve scope keywords such as `self` or `current` into `id` based scopes and scope attributes such as `name` or `uid` into `id` based scopes.
type ScopeResolver struct {
keywordResolvers map[string]KeywordScopeResolveFunc
attributeResolvers map[string]AttributeScopeResolveFunc
cache *localcache.CacheService
log log.Logger
}
func NewScopeResolver() ScopeResolver {
return ScopeResolver{
keywordResolvers: map[string]KeywordScopeResolveFunc{
"users:self": resolveUserSelf,
},
attributeResolvers: map[string]AttributeScopeResolveFunc{},
cache: localcache.New(ttl, cleanInterval),
log: log.New("accesscontrol.scoperesolution"),
}
}
func (s *ScopeResolver) AddKeywordResolver(keyword string, fn KeywordScopeResolveFunc) {
s.log.Debug("adding keyword resolution for '%v'", keyword)
s.keywordResolvers[keyword] = fn
}
func (s *ScopeResolver) AddAttributeResolver(prefix string, fn AttributeScopeResolveFunc) {
s.log.Debug("adding attribute resolution for '%v'", prefix)
s.attributeResolvers[prefix] = fn
}
func resolveUserSelf(u *models.SignedInUser) (string, error) {
return Scope("users", "id", fmt.Sprintf("%v", u.UserId)), nil
}
// GetResolveKeywordScopeMutator returns a function to resolve scope with keywords such as `self` or `current` into `id` based scopes
func (s *ScopeResolver) GetResolveKeywordScopeMutator(user *models.SignedInUser) ScopeMutator {
return func(_ context.Context, scope string) (string, error) {
var err error
// By default the scope remains unchanged
resolvedScope := scope
if fn, ok := s.keywordResolvers[scope]; ok {
resolvedScope, err = fn(user)
if err != nil {
return "", fmt.Errorf("could not resolve %v: %w", scope, err)
}
s.log.Debug("resolved '%v' to '%v'", scope, resolvedScope)
}
return resolvedScope, nil
}
}
type AttributeScopeResolveFunc func(ctx context.Context, orgID int64, initialScope string) (string, error)
// getCacheKey creates an identifier to fetch and store resolution of scopes in the cache
func getCacheKey(orgID int64, scope string) string {
return fmt.Sprintf("%s-%v", scope, orgID)
}
// GetResolveAttributeScopeMutator returns a function to resolve scopes with attributes such as `name` or `uid` into `id` based scopes
func (s *ScopeResolver) GetResolveAttributeScopeMutator(orgID int64) ScopeMutator {
return func(ctx context.Context, scope string) (string, error) {
// Check cache before computing the scope
if cachedScope, ok := s.cache.Get(getCacheKey(orgID, scope)); ok {
resolvedScope := cachedScope.(string)
s.log.Debug("used cache to resolve '%v' to '%v'", scope, resolvedScope)
return resolvedScope, nil
}
var err error
// By default the scope remains unchanged
resolvedScope := scope
prefix := ScopePrefix(scope)
if fn, ok := s.attributeResolvers[prefix]; ok {
resolvedScope, err = fn(ctx, orgID, scope)
if err != nil {
return "", fmt.Errorf("could not resolve %v: %w", scope, err)
}
// Cache result
s.cache.Set(getCacheKey(orgID, scope), resolvedScope, ttl)
s.log.Debug("resolved '%v' to '%v'", scope, resolvedScope)
}
return resolvedScope, nil
}
}
// ScopePrefix returns the prefix associated to a given scope // ScopePrefix returns the prefix associated to a given scope
// we assume prefixes are all in the form <resource>:<attribute>:<value> // we assume prefixes are all in the form <resource>:<attribute>:<value>
// ex: "datasources:name:test" returns "datasources:name:" // ex: "datasources:name:test" returns "datasources:name:"
@ -169,21 +70,6 @@ func ScopePrefix(scope string) string {
return strings.Join(parts, ":") return strings.Join(parts, ":")
} }
//Inject params into the evaluator's templated scopes. e.g. "settings:" + eval.Parameters(":id")
func ScopeInjector(params ScopeParams) ScopeMutator {
return func(_ context.Context, scope string) (string, error) {
tmpl, err := template.New("scope").Parse(scope)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err = tmpl.Execute(&buf, params); err != nil {
return "", err
}
return buf.String(), nil
}
}
// ScopeProvider provides methods that construct scopes // ScopeProvider provides methods that construct scopes
type ScopeProvider interface { type ScopeProvider interface {
GetResourceScope(resourceID string) string GetResourceScope(resourceID string) string

View File

@ -1,165 +1,12 @@
package accesscontrol package accesscontrol
import ( import (
"context"
"testing" "testing"
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
var testUser = &models.SignedInUser{ func Test_ScopePrefix(t *testing.T) {
UserId: 2,
OrgId: 3,
OrgName: "TestOrg",
OrgRole: models.ROLE_VIEWER,
Login: "testUser",
Name: "Test User",
Email: "testuser@example.org",
}
func TestResolveKeywordedScope(t *testing.T) {
tests := []struct {
name string
user *models.SignedInUser
permission Permission
want Permission
wantErr bool
}{
{
name: "no scope",
user: testUser,
permission: Permission{Action: "users:read"},
want: Permission{Action: "users:read"},
wantErr: false,
},
{
name: "user if resolution",
user: testUser,
permission: Permission{Action: "users:read", Scope: "users:self"},
want: Permission{Action: "users:read", Scope: "users:id:2"},
wantErr: false,
},
}
for _, tt := range tests {
var err error
t.Run(tt.name, func(t *testing.T) {
resolver := NewScopeResolver()
scopeModifier := resolver.GetResolveKeywordScopeMutator(tt.user)
tt.permission.Scope, err = scopeModifier(context.TODO(), tt.permission.Scope)
if tt.wantErr {
assert.Error(t, err, "expected an error during the resolution of the scope")
return
}
assert.NoError(t, err)
assert.EqualValues(t, tt.want, tt.permission, "permission did not match expected resolution")
})
}
}
func TestScopeResolver_ResolveAttribute(t *testing.T) {
// Calls allow us to see how many times the fakeDataSourceResolution has been called
calls := 0
fakeDataSourceResolution := func(ctx context.Context, orgID int64, initialScope string) (string, error) {
calls++
if initialScope == "datasources:name:testds" {
return Scope("datasources", "id", "1"), nil
} else if initialScope == "datasources:name:testds2" {
return Scope("datasources", "id", "2"), nil
} else if initialScope == "datasources:name:test:ds4" {
return Scope("datasources", "id", "4"), nil
} else if initialScope == "datasources:name:testds5*" {
return Scope("datasources", "id", "5"), nil
} else {
return "", models.ErrDataSourceNotFound
}
}
tests := []struct {
name string
orgID int64
evaluator Evaluator
wantEvaluator Evaluator
wantCalls int
wantErr error
}{
{
name: "should work with scope less permissions",
evaluator: EvalPermission("datasources:read"),
wantEvaluator: EvalPermission("datasources:read"),
wantCalls: 0,
},
{
name: "should handle an error",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds3")),
wantErr: models.ErrDataSourceNotFound,
wantCalls: 1,
},
{
name: "should resolve a scope",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "1")),
wantCalls: 1,
},
{
name: "should resolve nested scopes with cache",
orgID: 1,
evaluator: EvalAll(
EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
EvalAny(
EvalPermission("datasources:read", Scope("datasources", "name", "testds")),
EvalPermission("datasources:read", Scope("datasources", "name", "testds2")),
),
),
wantEvaluator: EvalAll(
EvalPermission("datasources:read", Scope("datasources", "id", "1")),
EvalAny(
EvalPermission("datasources:read", Scope("datasources", "id", "1")),
EvalPermission("datasources:read", Scope("datasources", "id", "2")),
),
),
wantCalls: 2,
},
{
name: "should resolve name with colon",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "test:ds4")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "4")),
wantCalls: 1,
},
{
name: "should resolve names with '*'",
orgID: 1,
evaluator: EvalPermission("datasources:read", Scope("datasources", "name", "testds5*")),
wantEvaluator: EvalPermission("datasources:read", Scope("datasources", "id", "5")),
wantCalls: 1,
},
}
for _, tt := range tests {
resolver := NewScopeResolver()
// Reset calls counter
calls = 0
// Register a resolution method
resolver.AddAttributeResolver("datasources:name:", fakeDataSourceResolution)
// Test
scopeModifier := resolver.GetResolveAttributeScopeMutator(tt.orgID)
resolvedEvaluator, err := tt.evaluator.MutateScopes(context.TODO(), scopeModifier)
if tt.wantErr != nil {
assert.ErrorAs(t, err, &tt.wantErr, "expected an error during the resolution of the scope")
return
}
assert.NoError(t, err)
assert.EqualValues(t, tt.wantEvaluator, resolvedEvaluator, "permission did not match expected resolution")
assert.Equal(t, tt.wantCalls, calls, "cache has not been used")
}
}
func Test_scopePrefix(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
scope string scope string

View File

@ -28,49 +28,47 @@ var (
ScopeFoldersProvider = ac.NewScopeProvider(ScopeFoldersRoot) ScopeFoldersProvider = ac.NewScopeProvider(ScopeFoldersRoot)
) )
// NewNameScopeResolver provides an AttributeScopeResolver that is able to convert a scope prefixed with "folders:name:" into an uid based scope. // NewFolderNameScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "folders:name:" into an uid based scope.
func NewNameScopeResolver(db Store) (string, ac.AttributeScopeResolveFunc) { func NewFolderNameScopeResolver(db Store) (string, ac.ScopeAttributeResolver) {
prefix := ScopeFoldersProvider.GetResourceScopeName("") prefix := ScopeFoldersProvider.GetResourceScopeName("")
resolver := func(ctx context.Context, orgID int64, scope string) (string, error) { return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) {
if !strings.HasPrefix(scope, prefix) { if !strings.HasPrefix(scope, prefix) {
return "", ac.ErrInvalidScope return nil, ac.ErrInvalidScope
} }
nsName := scope[len(prefix):] nsName := scope[len(prefix):]
if len(nsName) == 0 { if len(nsName) == 0 {
return "", ac.ErrInvalidScope return nil, ac.ErrInvalidScope
} }
folder, err := db.GetFolderByTitle(ctx, orgID, nsName) folder, err := db.GetFolderByTitle(ctx, orgID, nsName)
if err != nil { if err != nil {
return "", err return nil, err
} }
return ScopeFoldersProvider.GetResourceScopeUID(folder.Uid), nil return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.Uid)}, nil
} })
return prefix, resolver
} }
// NewIDScopeResolver provides an AttributeScopeResolver that is able to convert a scope prefixed with "folders:id:" into an uid based scope. // NewFolderIDScopeResolver provides an ScopeAttributeResolver that is able to convert a scope prefixed with "folders:id:" into an uid based scope.
func NewIDScopeResolver(db Store) (string, ac.AttributeScopeResolveFunc) { func NewFolderIDScopeResolver(db Store) (string, ac.ScopeAttributeResolver) {
prefix := ScopeFoldersProvider.GetResourceScope("") prefix := ScopeFoldersProvider.GetResourceScope("")
resolver := func(ctx context.Context, orgID int64, scope string) (string, error) { return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) {
if !strings.HasPrefix(scope, prefix) { if !strings.HasPrefix(scope, prefix) {
return "", ac.ErrInvalidScope return nil, ac.ErrInvalidScope
} }
id, err := strconv.ParseInt(scope[len(prefix):], 10, 64) id, err := strconv.ParseInt(scope[len(prefix):], 10, 64)
if err != nil { if err != nil {
return "", ac.ErrInvalidScope return nil, ac.ErrInvalidScope
} }
if id == 0 { if id == 0 {
return ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID), nil return []string{ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)}, nil
} }
folder, err := db.GetFolderByID(ctx, orgID, id) folder, err := db.GetFolderByID(ctx, orgID, id)
if err != nil { if err != nil {
return "", err return nil, err
} }
return ScopeFoldersProvider.GetResourceScopeUID(folder.Uid), nil return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.Uid)}, nil
} })
return prefix, resolver
} }

View File

@ -15,16 +15,16 @@ import (
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
func TestNewNameScopeResolver(t *testing.T) { func TestNewFolderNameScopeResolver(t *testing.T) {
t.Run("prefix should be expected", func(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) {
prefix, _ := NewNameScopeResolver(&FakeDashboardStore{}) prefix, _ := NewFolderNameScopeResolver(&FakeDashboardStore{})
require.Equal(t, "folders:name:", prefix) require.Equal(t, "folders:name:", prefix)
}) })
t.Run("resolver should convert to uid scope", func(t *testing.T) { t.Run("resolver should convert to uid scope", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewNameScopeResolver(dashboardStore) _, resolver := NewFolderNameScopeResolver(dashboardStore)
orgId := rand.Int63() orgId := rand.Int63()
title := "Very complex :title with: and /" + util.GenerateShortUID() title := "Very complex :title with: and /" + util.GenerateShortUID()
@ -36,53 +36,54 @@ func TestNewNameScopeResolver(t *testing.T) {
scope := "folders:name:" + title scope := "folders:name:" + title
resolvedScope, err := resolver(context.Background(), orgId, scope) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, resolvedScopes, 1)
require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScope) require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScopes[0])
dashboardStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) dashboardStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title)
}) })
t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { t.Run("resolver should fail if input scope is not expected", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewNameScopeResolver(dashboardStore) _, resolver := NewFolderNameScopeResolver(dashboardStore)
_, err := resolver(context.Background(), rand.Int63(), "folders:id:123") _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:id:123")
require.ErrorIs(t, err, ac.ErrInvalidScope) require.ErrorIs(t, err, ac.ErrInvalidScope)
}) })
t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) { t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewNameScopeResolver(dashboardStore) _, resolver := NewFolderNameScopeResolver(dashboardStore)
_, err := resolver(context.Background(), rand.Int63(), "folders:name:") _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:name:")
require.ErrorIs(t, err, ac.ErrInvalidScope) require.ErrorIs(t, err, ac.ErrInvalidScope)
}) })
t.Run("returns 'not found' if folder does not exist", func(t *testing.T) { t.Run("returns 'not found' if folder does not exist", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewNameScopeResolver(dashboardStore) _, resolver := NewFolderNameScopeResolver(dashboardStore)
orgId := rand.Int63() orgId := rand.Int63()
dashboardStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrDashboardNotFound).Once() dashboardStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrDashboardNotFound).Once()
scope := "folders:name:" + util.GenerateShortUID() scope := "folders:name:" + util.GenerateShortUID()
resolvedScope, err := resolver(context.Background(), orgId, scope) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope)
require.ErrorIs(t, err, models.ErrDashboardNotFound) require.ErrorIs(t, err, models.ErrDashboardNotFound)
require.Empty(t, resolvedScope) require.Nil(t, resolvedScopes)
}) })
} }
func TestNewIDScopeResolver(t *testing.T) { func TestNewFolderIDScopeResolver(t *testing.T) {
t.Run("prefix should be expected", func(t *testing.T) { t.Run("prefix should be expected", func(t *testing.T) {
prefix, _ := NewIDScopeResolver(&FakeDashboardStore{}) prefix, _ := NewFolderIDScopeResolver(&FakeDashboardStore{})
require.Equal(t, "folders:id:", prefix) require.Equal(t, "folders:id:", prefix)
}) })
t.Run("resolver should convert to uid scope", func(t *testing.T) { t.Run("resolver should convert to uid scope", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewIDScopeResolver(dashboardStore) _, resolver := NewFolderIDScopeResolver(dashboardStore)
orgId := rand.Int63() orgId := rand.Int63()
uid := util.GenerateShortUID() uid := util.GenerateShortUID()
@ -92,18 +93,18 @@ func TestNewIDScopeResolver(t *testing.T) {
scope := "folders:id:" + strconv.FormatInt(db.Id, 10) scope := "folders:id:" + strconv.FormatInt(db.Id, 10)
resolvedScope, err := resolver(context.Background(), orgId, scope) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, resolvedScopes, 1)
require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScope) require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScopes[0])
dashboardStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.Id) dashboardStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.Id)
}) })
t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { t.Run("resolver should fail if input scope is not expected", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewIDScopeResolver(dashboardStore) _, resolver := NewFolderIDScopeResolver(dashboardStore)
_, err := resolver(context.Background(), rand.Int63(), "folders:uid:123") _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:uid:123")
require.ErrorIs(t, err, ac.ErrInvalidScope) require.ErrorIs(t, err, ac.ErrInvalidScope)
}) })
@ -112,33 +113,34 @@ func TestNewIDScopeResolver(t *testing.T) {
dashboardStore = &FakeDashboardStore{} dashboardStore = &FakeDashboardStore{}
orgId = rand.Int63() orgId = rand.Int63()
scope = "folders:id:0" scope = "folders:id:0"
_, resolver = NewIDScopeResolver(dashboardStore) _, resolver = NewFolderIDScopeResolver(dashboardStore)
) )
resolvedScope, err := resolver(context.Background(), orgId, scope) resolved, err := resolver.Resolve(context.Background(), orgId, scope)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "folders:uid:general", resolvedScope) require.Len(t, resolved, 1)
require.Equal(t, "folders:uid:general", resolved[0])
}) })
t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) { t.Run("resolver should fail if resource of input scope is empty", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewIDScopeResolver(dashboardStore) _, resolver := NewFolderIDScopeResolver(dashboardStore)
_, err := resolver(context.Background(), rand.Int63(), "folders:id:") _, err := resolver.Resolve(context.Background(), rand.Int63(), "folders:id:")
require.ErrorIs(t, err, ac.ErrInvalidScope) require.ErrorIs(t, err, ac.ErrInvalidScope)
}) })
t.Run("returns 'not found' if folder does not exist", func(t *testing.T) { t.Run("returns 'not found' if folder does not exist", func(t *testing.T) {
dashboardStore := &FakeDashboardStore{} dashboardStore := &FakeDashboardStore{}
_, resolver := NewIDScopeResolver(dashboardStore) _, resolver := NewFolderIDScopeResolver(dashboardStore)
orgId := rand.Int63() orgId := rand.Int63()
dashboardStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrDashboardNotFound).Once() dashboardStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrDashboardNotFound).Once()
scope := "folders:id:10" scope := "folders:id:10"
resolvedScope, err := resolver(context.Background(), orgId, scope) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope)
require.ErrorIs(t, err, models.ErrDashboardNotFound) require.ErrorIs(t, err, models.ErrDashboardNotFound)
require.Empty(t, resolvedScope) require.Nil(t, resolvedScopes)
}) })
} }

View File

@ -32,8 +32,8 @@ func ProvideFolderService(
searchService *search.SearchService, features featuremgmt.FeatureToggles, permissionsServices accesscontrol.PermissionsServices, searchService *search.SearchService, features featuremgmt.FeatureToggles, permissionsServices accesscontrol.PermissionsServices,
ac accesscontrol.AccessControl, sqlStore sqlstore.Store, ac accesscontrol.AccessControl, sqlStore sqlstore.Store,
) *FolderServiceImpl { ) *FolderServiceImpl {
ac.RegisterAttributeScopeResolver(dashboards.NewNameScopeResolver(dashboardStore)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(dashboardStore))
ac.RegisterAttributeScopeResolver(dashboards.NewIDScopeResolver(dashboardStore)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(dashboardStore))
return &FolderServiceImpl{ return &FolderServiceImpl{
cfg: cfg, cfg: cfg,

View File

@ -68,8 +68,8 @@ func ProvideService(
ac: ac, ac: ac,
} }
ac.RegisterAttributeScopeResolver(NewNameScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
ac.RegisterAttributeScopeResolver(NewIDScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewIDScopeResolver(store))
return s return s
} }
@ -82,55 +82,55 @@ type DataSourceRetriever interface {
const secretType = "datasource" const secretType = "datasource"
// NewNameScopeResolver provides an AttributeScopeResolver able to // NewNameScopeResolver provides an ScopeAttributeResolver able to
// translate a scope prefixed with "datasources:name:" into an uid based scope. // translate a scope prefixed with "datasources:name:" into an uid based scope.
func NewNameScopeResolver(db DataSourceRetriever) (string, accesscontrol.AttributeScopeResolveFunc) { func NewNameScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttributeResolver) {
prefix := datasources.ScopeProvider.GetResourceScopeName("") prefix := datasources.ScopeProvider.GetResourceScopeName("")
return prefix, func(ctx context.Context, orgID int64, initialScope string) (string, error) { return prefix, accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
if !strings.HasPrefix(initialScope, prefix) { if !strings.HasPrefix(initialScope, prefix) {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
dsName := initialScope[len(prefix):] dsName := initialScope[len(prefix):]
if dsName == "" { if dsName == "" {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
query := models.GetDataSourceQuery{Name: dsName, OrgId: orgID} query := models.GetDataSourceQuery{Name: dsName, OrgId: orgID}
if err := db.GetDataSource(ctx, &query); err != nil { if err := db.GetDataSource(ctx, &query); err != nil {
return "", err return nil, err
} }
return datasources.ScopeProvider.GetResourceScopeUID(query.Result.Uid), nil return []string{datasources.ScopeProvider.GetResourceScopeUID(query.Result.Uid)}, nil
} })
} }
// NewIDScopeResolver provides an AttributeScopeResolver able to // NewIDScopeResolver provides an ScopeAttributeResolver able to
// translate a scope prefixed with "datasources:id:" into an uid based scope. // translate a scope prefixed with "datasources:id:" into an uid based scope.
func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.AttributeScopeResolveFunc) { func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttributeResolver) {
prefix := datasources.ScopeProvider.GetResourceScope("") prefix := datasources.ScopeProvider.GetResourceScope("")
return prefix, func(ctx context.Context, orgID int64, initialScope string) (string, error) { return prefix, accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
if !strings.HasPrefix(initialScope, prefix) { if !strings.HasPrefix(initialScope, prefix) {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
id := initialScope[len(prefix):] id := initialScope[len(prefix):]
if id == "" { if id == "" {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
dsID, err := strconv.ParseInt(id, 10, 64) dsID, err := strconv.ParseInt(id, 10, 64)
if err != nil { if err != nil {
return "", accesscontrol.ErrInvalidScope return nil, accesscontrol.ErrInvalidScope
} }
query := models.GetDataSourceQuery{Id: dsID, OrgId: orgID} query := models.GetDataSourceQuery{Id: dsID, OrgId: orgID}
if err := db.GetDataSource(ctx, &query); err != nil { if err := db.GetDataSource(ctx, &query); err != nil {
return "", err return nil, err
} }
return datasources.ScopeProvider.GetResourceScopeUID(query.Result.Uid), nil return []string{datasources.ScopeProvider.GetResourceScopeUID(query.Result.Uid)}, nil
} })
} }
func (s *Service) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error { func (s *Service) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error {

View File

@ -109,13 +109,14 @@ func TestService_NameScopeResolver(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
resolved, err := resolver(context.Background(), 1, tc.given) resolved, err := resolver.Resolve(context.Background(), 1, tc.given)
if tc.wantErr != nil { if tc.wantErr != nil {
require.Error(t, err) require.Error(t, err)
require.Equal(t, tc.wantErr, err) require.Equal(t, tc.wantErr, err)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.want, resolved) require.Len(t, resolved, 1)
require.Equal(t, tc.want, resolved[0])
} }
}) })
} }
@ -164,13 +165,14 @@ func TestService_IDScopeResolver(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
resolved, err := resolver(context.Background(), 1, tc.given) resolved, err := resolver.Resolve(context.Background(), 1, tc.given)
if tc.wantErr != nil { if tc.wantErr != nil {
require.Error(t, err) require.Error(t, err)
require.Equal(t, tc.wantErr, err) require.Equal(t, tc.wantErr, err)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.want, resolved) require.Len(t, resolved, 1)
require.Equal(t, tc.want, resolved[0])
} }
}) })
} }