Authz: add support to use folder api to fetch folder tree (#100038)

* Add FolderStore interface

* Authz: add implementation to use folders api and use it inproc with loopback config

* Add tracing and add rest.Config for talking with folder api using access tokens

* Restructure test to get rid of circular dependencies in tests

* use correct group version kind

---------

Co-authored-by: gamab <gabriel.mabille@grafana.com>
This commit is contained in:
Karl Persson
2025-02-13 11:59:59 +01:00
committed by GitHub
co-authored by gamab
parent ae9837b793
commit 1b1954de28
14 changed files with 367 additions and 172 deletions
@@ -3,6 +3,8 @@ package authz
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/fullstorydev/grpchan"
@@ -11,6 +13,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"k8s.io/client-go/rest"
authnlib "github.com/grafana/authlib/authn"
authzlib "github.com/grafana/authlib/authz"
@@ -22,6 +25,8 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -64,6 +69,10 @@ func ProvideAuthZClient(
// Register the server
server := rbac.NewService(
sql,
// When running in-proc we get a injection cycle between
// authz client, resource client and apiserver so we need to use
// package level function to get rest config
store.NewAPIFolderStore(tracer, apiserver.GetRestConfig),
legacy.NewLegacySQLStores(sql),
store.NewUnionPermissionStore(
store.NewStaticPermissionStore(acService),
@@ -201,3 +210,67 @@ func newCloudLegacyClient(authCfg *Cfg, tracer tracing.Tracer) (authlib.AccessCl
return client, nil
}
func RegisterRBACAuthZService(
handler grpcserver.Provider,
db legacysql.LegacyDatabaseProvider,
tracer tracing.Tracer,
reg prometheus.Registerer,
cache cache.Cache,
exchangeClient authnlib.TokenExchanger,
folderAPIURL string,
) {
var folderStore store.FolderStore
// FIXME: for now we default to using database read proxy for folders if the api url is not configured.
// we should remove this and the sql implementation once we have verified that is works correctly
if folderAPIURL == "" {
folderStore = store.NewSQLFolderStore(db, tracer)
} else {
folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) *rest.Config {
return &rest.Config{
Host: folderAPIURL,
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt}
},
QPS: 50,
Burst: 100,
}
})
}
server := rbac.NewService(
db,
folderStore,
legacy.NewLegacySQLStores(db),
store.NewSQLPermissionStore(db, tracer),
log.New("authz-grpc-server"),
tracer,
reg,
cache,
)
srv := handler.GetServer()
authzv1.RegisterAuthzServiceServer(srv, server)
authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
}
var _ http.RoundTripper = tokenExhangeRoundTripper{}
type tokenExhangeRoundTripper struct {
te authnlib.TokenExchanger
rt http.RoundTripper
}
func (t tokenExhangeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
res, err := t.te.Exchange(r.Context(), authnlib.TokenExchangeRequest{
Namespace: "*",
Audiences: []string{"folder.grafana.app"},
})
if err != nil {
return nil, fmt.Errorf("create access token: %w", err)
}
r.Header.Set("X-Access-Token", "Bearer "+res.Token)
return t.rt.RoundTrip(r)
}
+30 -26
View File
@@ -17,7 +17,7 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/grafana/authlib/cache"
claims "github.com/grafana/authlib/types"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -41,6 +41,7 @@ type Service struct {
authzextv1.UnimplementedAuthzExtentionServiceServer
store store.Store
folderStore store.FolderStore
permissionStore store.PermissionStore
identityStore legacy.LegacyIdentityStore
@@ -63,6 +64,7 @@ type Service struct {
func NewService(
sql legacysql.LegacyDatabaseProvider,
folderStore store.FolderStore,
identityStore legacy.LegacyIdentityStore,
permissionStore store.PermissionStore,
logger log.Logger,
@@ -72,6 +74,7 @@ func NewService(
) *Service {
return &Service{
store: store.NewStore(sql, tracer),
folderStore: folderStore,
permissionStore: permissionStore,
identityStore: identityStore,
logger: logger,
@@ -209,40 +212,42 @@ func (s *Service) validateListRequest(ctx context.Context, req *authzv1.ListRequ
return listReq, nil
}
func validateNamespace(ctx context.Context, nameSpace string) (claims.NamespaceInfo, error) {
func validateNamespace(ctx context.Context, nameSpace string) (types.NamespaceInfo, error) {
if nameSpace == "" {
return claims.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required")
return types.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required")
}
authInfo, has := claims.AuthInfoFrom(ctx)
authInfo, has := types.AuthInfoFrom(ctx)
if !has {
return claims.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context")
return types.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context")
}
if !claims.NamespaceMatches(authInfo.GetNamespace(), nameSpace) {
return claims.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match")
if !types.NamespaceMatches(authInfo.GetNamespace(), nameSpace) {
return types.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match")
}
ns, err := claims.ParseNamespace(nameSpace)
ns, err := types.ParseNamespace(nameSpace)
if err != nil {
return claims.NamespaceInfo{}, err
return types.NamespaceInfo{}, err
}
return ns, nil
}
func (s *Service) validateSubject(ctx context.Context, subject string) (string, claims.IdentityType, error) {
func (s *Service) validateSubject(ctx context.Context, subject string) (string, types.IdentityType, error) {
if subject == "" {
return "", "", status.Error(codes.InvalidArgument, "subject is required")
}
ctxLogger := s.logger.FromContext(ctx)
identityType, userUID, err := claims.ParseTypeID(subject)
identityType, userUID, err := types.ParseTypeID(subject)
if err != nil {
return "", "", err
}
// Permission check currently only checks user, anonymous user, service account and renderer permissions
if !(identityType == claims.TypeUser || identityType == claims.TypeServiceAccount || identityType == claims.TypeAnonymous || identityType == claims.TypeRenderService) {
if !types.IsIdentityType(identityType, types.TypeUser, types.TypeServiceAccount, types.TypeAnonymous, types.TypeRenderService) {
ctxLogger.Error("unsupported identity type", "type", identityType)
return "", "", status.Error(codes.PermissionDenied, "unsupported identity type")
}
return userUID, identityType, nil
}
@@ -264,30 +269,29 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri
return action, nil
}
func (s *Service) getIdentityPermissions(ctx context.Context, ns claims.NamespaceInfo, idType claims.IdentityType, userID, action string) (map[string]bool, error) {
func (s *Service) getIdentityPermissions(ctx context.Context, ns types.NamespaceInfo, idType types.IdentityType, userID, action string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getIdentityPermissions")
defer span.End()
// When checking folder creation permissions, also check edit and admin action sets for folder, as the scoped folder create actions aren't stored in the DB separately
var actionSets []string
if action == "folders:create" {
actionSets = append(actionSets, "folders:edit")
actionSets = append(actionSets, "folders:admin")
actionSets = append(actionSets, "folders:edit", "folders:admin")
}
switch idType {
case claims.TypeAnonymous:
case types.TypeAnonymous:
return s.getAnonymousPermissions(ctx, ns, action, actionSets)
case claims.TypeRenderService:
case types.TypeRenderService:
return s.getRendererPermissions(ctx, action)
case claims.TypeUser, claims.TypeServiceAccount:
case types.TypeUser, types.TypeServiceAccount:
return s.getUserPermissions(ctx, ns, userID, action, actionSets)
default:
return nil, fmt.Errorf("unsupported identity type: %s", idType)
}
}
func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) {
func (s *Service) getUserPermissions(ctx context.Context, ns types.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserPermissions")
defer span.End()
@@ -342,7 +346,7 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf
return res.(map[string]bool), nil
}
func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) {
func (s *Service) getAnonymousPermissions(ctx context.Context, ns types.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getAnonymousPermissions")
defer span.End()
@@ -378,7 +382,7 @@ func (s *Service) getRendererPermissions(ctx context.Context, action string) (ma
return map[string]bool{}, nil
}
func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) {
func (s *Service) GetUserIdentifiers(ctx context.Context, ns types.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) {
uidCacheKey := userIdentifierCacheKey(ns.Value, userUID)
if cached, ok := s.idCache.Get(ctx, uidCacheKey); ok {
return &cached, nil
@@ -397,7 +401,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf
userIDQuery = store.UserIdentifierQuery{UserUID: userUID}
}
userIdentifiers, err := s.store.GetUserIdentifiers(ctx, userIDQuery)
if err != nil || userIdentifiers == nil {
if err != nil {
return nil, fmt.Errorf("could not get user internal id: %w", err)
}
@@ -407,7 +411,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf
return userIdentifiers, nil
}
func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) {
func (s *Service) getUserTeams(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserTeams")
defer span.End()
@@ -441,7 +445,7 @@ func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, use
return teamIDs, nil
}
func (s *Service) getUserBasicRole(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) {
func (s *Service) getUserBasicRole(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserBasicRole")
defer span.End()
@@ -535,7 +539,7 @@ func (s *Service) checkInheritedPermissions(ctx context.Context, scopeMap map[st
return false, nil
}
func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) (folderTree, error) {
func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) (folderTree, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.buildFolderTree")
defer span.End()
@@ -545,7 +549,7 @@ func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo)
}
res, err, _ := s.sf.Do(ns.Value+"_buildFolderTree", func() (interface{}, error) {
folders, err := s.store.GetFolders(ctx, ns)
folders, err := s.folderStore.ListFolders(ctx, ns)
if err != nil {
return nil, fmt.Errorf("could not get folders: %w", err)
}
+2 -1
View File
@@ -620,6 +620,7 @@ func setupService() *Service {
folderCache: newCacheWrap[folderTree](cache, logger, shortCacheTTL),
store: fStore,
permissionStore: fStore,
folderStore: fStore,
identityStore: &fakeIdentityStore{},
sf: new(singleflight.Group),
}
@@ -663,7 +664,7 @@ func (f *fakeStore) GetUserPermissions(ctx context.Context, namespace claims.Nam
return f.userPermissions, nil
}
func (f *fakeStore) GetFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) {
func (f *fakeStore) ListFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) {
f.calls++
if f.err {
return nil, fmt.Errorf("store error")
@@ -0,0 +1,158 @@
package store
import (
"context"
"fmt"
"github.com/grafana/authlib/types"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/pager"
"github.com/grafana/grafana/pkg/apimachinery/utils"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
type FolderStore interface {
ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error)
}
type Folder struct {
UID string
ParentUID *string
}
var _ FolderStore = (*SQLFolderStore)(nil)
func NewSQLFolderStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *SQLFolderStore {
return &SQLFolderStore{sql, tracer}
}
type SQLFolderStore struct {
sql legacysql.LegacyDatabaseProvider
tracer tracing.Tracer
}
var sqlFolders = mustTemplate("folder_query.sql")
type listFoldersQuery struct {
sqltemplate.SQLTemplate
Query *FolderQuery
FolderTable string
}
type FolderQuery struct {
OrgID int64
}
func (r listFoldersQuery) Validate() error {
return nil
}
func newListFolders(sql *legacysql.LegacyDatabaseHelper, query *FolderQuery) listFoldersQuery {
return listFoldersQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
Query: query,
FolderTable: sql.Table("folder"),
}
}
func (s *SQLFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.ListFolders")
defer span.End()
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
query := newListFolders(sql, &FolderQuery{OrgID: ns.OrgID})
q, err := sqltemplate.Execute(sqlFolders, query)
if err != nil {
return nil, err
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, query.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
var folders []Folder
for rows.Next() {
var folder Folder
if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil {
return nil, err
}
folders = append(folders, folder)
}
return folders, nil
}
var _ FolderStore = (*APIFolderStore)(nil)
func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) *rest.Config) *APIFolderStore {
return &APIFolderStore{tracer, configProvider}
}
type APIFolderStore struct {
tracer tracing.Tracer
configProvider func(ctx context.Context) *rest.Config
}
func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) {
ctx, span := s.tracer.Start(ctx, "authz.apistore.ListFolders")
defer span.End()
client, err := s.client(ctx, ns.Value)
if err != nil {
return nil, fmt.Errorf("create resource client: %w", err)
}
p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
return client.List(ctx, opts)
})
const defaultPageSize = 500
folders := make([]Folder, 0, defaultPageSize)
err = p.EachListItem(ctx, metav1.ListOptions{Limit: defaultPageSize}, func(obj runtime.Object) error {
object, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
folder := Folder{UID: object.GetName()}
parent := object.GetFolder()
if parent != "" {
folder.ParentUID = &parent
}
folders = append(folders, folder)
return nil
})
if err != nil {
return nil, fmt.Errorf("fetching folders: %w", err)
}
return folders, nil
}
func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) {
client, err := dynamic.NewForConfig(s.configProvider(ctx))
if err != nil {
return nil, err
}
return client.Resource(folderv0alpha1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil
}
-18
View File
@@ -19,21 +19,3 @@ type UserIdentifierQuery struct {
UserID int64
UserUID string
}
type FolderQuery struct {
OrgID int64
}
type DashboardQuery struct {
OrgID int64
}
type Folder struct {
UID string
ParentUID *string
}
type Dashboard struct {
UID string
ParentUID *string
}
-1
View File
@@ -16,7 +16,6 @@ var (
sqlQueryBasicRoles = mustTemplate("basic_role_query.sql")
sqlUserIdentifiers = mustTemplate("user_identifier_query.sql")
sqlFolders = mustTemplate("folder_query.sql")
)
func mustTemplate(filename string) *template.Template {
-39
View File
@@ -15,7 +15,6 @@ import (
type Store interface {
GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error)
GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, query BasicRoleQuery) (*BasicRole, error)
GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error)
}
type StoreImpl struct {
@@ -104,41 +103,3 @@ func (s *StoreImpl) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo,
return &role, nil
}
func (s *StoreImpl) GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetFolders")
defer span.End()
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
query := FolderQuery{OrgID: ns.OrgID}
req := newGetFolders(sql, &query)
q, err := sqltemplate.Execute(sqlFolders, req)
if err != nil {
return nil, err
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
var folders []Folder
for rows.Next() {
var folder Folder
if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil {
return nil, err
}
folders = append(folders, folder)
}
return folders, nil
}
-37
View File
@@ -1,37 +0,0 @@
package authz
import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
cache "github.com/grafana/authlib/cache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/prometheus/client_golang/prometheus"
)
func RegisterRBACAuthZService(
handler grpcserver.Provider,
db legacysql.LegacyDatabaseProvider,
tracer tracing.Tracer,
reg prometheus.Registerer,
cache cache.Cache) {
server := rbac.NewService(
db,
legacy.NewLegacySQLStores(db),
store.NewSQLPermissionStore(db, tracer),
log.New("authz-grpc-server"),
tracer,
reg,
cache,
)
srv := handler.GetServer()
authzv1.RegisterAuthzServiceServer(srv, server)
authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
}