mirror of https://github.com/grafana/grafana
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 scopespull/48589/head
parent
9622e7457e
commit
de50f39c12
@ -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 |
||||
}) |
@ -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") |
||||
} |
||||
} |
Loading…
Reference in new issue