mirror of https://github.com/grafana/grafana
AuthZ: Scope resolution (#107948)
* AuthZ: Scope resolution * Account for PR feedback Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>pull/108236/head
parent
c6ebefdfb6
commit
4b217c601a
@ -0,0 +1,136 @@ |
||||
package rbac |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/grafana/authlib/types" |
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy" |
||||
"github.com/grafana/grafana/pkg/services/accesscontrol" |
||||
) |
||||
|
||||
type ScopeResolverFunc func(scope string) (string, error) |
||||
|
||||
func (s *Service) fetchTeams(ctx context.Context, ns types.NamespaceInfo) (map[int64]string, error) { |
||||
key := teamIDsCacheKey(ns.Value) |
||||
res, err, _ := s.sf.Do(key, func() (any, error) { |
||||
teams, err := s.identityStore.ListTeams(ctx, ns, legacy.ListTeamQuery{}) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("could not fetch teams: %w", err) |
||||
} |
||||
teamIDs := make(map[int64]string, len(teams.Teams)) |
||||
for _, team := range teams.Teams { |
||||
teamIDs[team.ID] = team.UID |
||||
} |
||||
return teamIDs, nil |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
teamIDs := res.(map[int64]string) |
||||
s.teamIDCache.Set(ctx, key, teamIDs) |
||||
return teamIDs, nil |
||||
} |
||||
|
||||
// Should return an error if we fail to build the resolver.
|
||||
func (s *Service) newTeamNameResolver(ctx context.Context, ns types.NamespaceInfo) (ScopeResolverFunc, error) { |
||||
teamIDs, cacheHit := s.teamIDCache.Get(ctx, teamIDsCacheKey(ns.Value)) |
||||
if !cacheHit { |
||||
var err error |
||||
teamIDs, err = s.fetchTeams(ctx, ns) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("could not build resolver: %w", err) |
||||
} |
||||
} |
||||
|
||||
return func(scope string) (string, error) { |
||||
teamIDStr := strings.TrimPrefix(scope, "teams:id:") |
||||
if teamIDStr == "" { |
||||
return "", fmt.Errorf("team ID is empty") |
||||
} |
||||
if teamIDStr == "*" { |
||||
return "teams:uid:*", nil |
||||
} |
||||
teamID, err := strconv.ParseInt(teamIDStr, 10, 64) |
||||
if err != nil { |
||||
return "", fmt.Errorf("invalid team ID %s: %w", teamIDStr, err) |
||||
} |
||||
if teamName, ok := teamIDs[teamID]; ok { |
||||
return "teams:uid:" + teamName, nil |
||||
} |
||||
|
||||
// Stale cache recovery: Try to fetch the teams again.
|
||||
if cacheHit { |
||||
// Potential future improvement: if multiple threads have the same stale cache,
|
||||
// they might refetch teams separately and asynchronously. We could use a more sophisticated
|
||||
// approach to avoid this. Like checking if the cache has been updated meanwhile.
|
||||
cacheHit = false |
||||
teamIDs, err = s.fetchTeams(ctx, ns) |
||||
if err != nil { |
||||
// Other improvement: Stop the calling loop if we fail to fetch teams.
|
||||
return "", err |
||||
} |
||||
if teamName, ok := teamIDs[teamID]; ok { |
||||
return "teams:uid:" + teamName, nil |
||||
} |
||||
} |
||||
|
||||
return "", fmt.Errorf("team ID %s not found", teamIDStr) |
||||
}, nil |
||||
} |
||||
|
||||
func (s *Service) nameResolver(ctx context.Context, ns types.NamespaceInfo, scopePrefix string) (ScopeResolverFunc, error) { |
||||
if scopePrefix == "teams:id:" { |
||||
return s.newTeamNameResolver(ctx, ns) |
||||
} |
||||
// No resolver found for the given scope prefix.
|
||||
return nil, nil |
||||
} |
||||
|
||||
// resolveScopeMap translates scopes like "teams:id:1" to "teams:uid:t1".
|
||||
// It assumes only one scope resolver is needed for a given scope map, based on the first valid scope encountered.
|
||||
func (s *Service) resolveScopeMap(ctx context.Context, ns types.NamespaceInfo, scopeMap map[string]bool) (map[string]bool, error) { |
||||
var ( |
||||
prefix string |
||||
scopeResolver ScopeResolverFunc |
||||
err error |
||||
) |
||||
for scope := range scopeMap { |
||||
// Find the resolver based on the first scope with a valid prefix
|
||||
if prefix == "" { |
||||
if len(strings.Split(scope, ":")) < 3 { |
||||
// Skip scopes that don't have at least 3 parts (e.g., "*", "teams:*")
|
||||
// This is because we expect scopes to be in the format "resource:attribute:value".
|
||||
continue |
||||
} |
||||
|
||||
// Initialize the scope resolver only once
|
||||
prefix = accesscontrol.ScopePrefix(scope) |
||||
scopeResolver, err = s.nameResolver(ctx, ns, prefix) |
||||
if err != nil { |
||||
s.logger.FromContext(ctx).Error("failed to create scope resolver", "prefix", prefix, "error", err) |
||||
return nil, err |
||||
} |
||||
if scopeResolver == nil { |
||||
break // No resolver found for this prefix
|
||||
} |
||||
} |
||||
|
||||
// Skip scopes that do not have the expected prefix
|
||||
if !strings.HasPrefix(scope, prefix) { |
||||
continue |
||||
} |
||||
resolved, err := scopeResolver(scope) |
||||
if err != nil { |
||||
s.logger.FromContext(ctx).Warn("could not resolve scope name", "scope", scope, "error", err) |
||||
continue // Still want to process other scopes even if one fails.
|
||||
} |
||||
if resolved != "" { |
||||
scopeMap[resolved] = true |
||||
delete(scopeMap, scope) |
||||
} |
||||
} |
||||
return scopeMap, nil |
||||
} |
@ -0,0 +1,163 @@ |
||||
package rbac |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/authlib/types" |
||||
"github.com/grafana/grafana/pkg/services/team" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestService_resolveScopeMap(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
scopeMap map[string]bool |
||||
ns types.NamespaceInfo |
||||
cache map[string]map[int64]string // Namespace: team ID -> UID cache
|
||||
store []team.Team |
||||
want map[string]bool |
||||
}{ |
||||
{ |
||||
name: "Should resolve team IDs to team UIDs", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:1": true, |
||||
"teams:id:2": true, |
||||
}, |
||||
store: []team.Team{ |
||||
{ID: 1, UID: "t1"}, |
||||
{ID: 2, UID: "t2"}, |
||||
}, |
||||
want: map[string]bool{ |
||||
"teams:uid:t1": true, |
||||
"teams:uid:t2": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Should use cache", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:1": true, |
||||
"teams:id:2": true, |
||||
}, |
||||
cache: map[string]map[int64]string{ |
||||
"org-2": {1: "t1", 2: "t2"}, |
||||
}, |
||||
want: map[string]bool{ |
||||
"teams:uid:t1": true, |
||||
"teams:uid:t2": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Shouldn't use cache from another namespace", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:1": true, |
||||
}, |
||||
cache: map[string]map[int64]string{ |
||||
"org-31": {1: "org31-t1"}, |
||||
}, |
||||
store: []team.Team{{ID: 1, UID: "org2-t1"}}, |
||||
want: map[string]bool{ |
||||
"teams:uid:org2-t1": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Should handle wildcard", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{"teams:id:*": true}, |
||||
store: []team.Team{{ID: 1, UID: "t1"}, {ID: 2, UID: "t2"}}, |
||||
want: map[string]bool{"teams:uid:*": true}, |
||||
}, |
||||
{ |
||||
name: "Should skip short scopes", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:short": true, |
||||
"teams:*": true, |
||||
"*": true, |
||||
"teams:id:1": true, |
||||
}, |
||||
store: []team.Team{{ID: 1, UID: "t1"}}, |
||||
want: map[string]bool{ |
||||
"teams:short": true, |
||||
"teams:uid:t1": true, |
||||
"teams:*": true, |
||||
"*": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Should gracefully handle invalid scopes", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:": true, |
||||
"teams:id:NaN": true, |
||||
"teams:id:1": true, |
||||
}, |
||||
store: []team.Team{{ID: 1, UID: "t1"}}, |
||||
want: map[string]bool{ |
||||
"teams:id:": true, |
||||
"teams:id:NaN": true, |
||||
"teams:uid:t1": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Should recover from stale cache", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:1": true, |
||||
"teams:id:2": true, |
||||
"teams:id:3": true, |
||||
}, |
||||
cache: map[string]map[int64]string{ |
||||
"org-2": {1: "t1"}, |
||||
}, |
||||
store: []team.Team{ |
||||
{ID: 1, UID: "t1"}, |
||||
{ID: 2, UID: "t2"}, |
||||
{ID: 3, UID: "t3"}, |
||||
}, |
||||
want: map[string]bool{ |
||||
"teams:uid:t1": true, |
||||
"teams:uid:t2": true, |
||||
"teams:uid:t3": true, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Should skip unknown team IDs", |
||||
ns: types.NamespaceInfo{Value: "org-2", OrgID: 2}, |
||||
scopeMap: map[string]bool{ |
||||
"teams:id:1": true, |
||||
}, |
||||
store: []team.Team{ |
||||
{ID: 2, UID: "t2"}, |
||||
}, |
||||
want: map[string]bool{ |
||||
"teams:id:1": true, |
||||
}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
s := setupService() |
||||
s.identityStore = &fakeIdentityStore{ |
||||
disableNsCheck: true, |
||||
teams: tt.store, |
||||
} |
||||
if tt.cache != nil { |
||||
for ns, cache := range tt.cache { |
||||
s.teamIDCache.Set(context.Background(), teamIDsCacheKey(ns), cache) |
||||
} |
||||
} |
||||
got, err := s.resolveScopeMap(context.Background(), tt.ns, tt.scopeMap) |
||||
require.NoError(t, err) |
||||
|
||||
require.Len(t, got, len(tt.want)) |
||||
for scope := range tt.want { |
||||
_, ok := got[scope] |
||||
require.True(t, ok) |
||||
} |
||||
}) |
||||
} |
||||
} |
Loading…
Reference in new issue